mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bce306238b | ||
|
|
c1bcfd930b | ||
|
|
6953ba6506 | ||
|
|
a68a0c2c5f | ||
|
|
a8e0effb32 | ||
|
|
c663630bbd | ||
|
|
deb1bdc266 | ||
|
|
9c7925de58 | ||
|
|
4351445467 | ||
|
|
9503537041 | ||
|
|
1aef1ec662 | ||
|
|
946b6e58fd | ||
|
|
ac6e4e9f96 | ||
|
|
c4b4516890 | ||
|
|
75f58c4fe7 | ||
|
|
fb69bf3314 | ||
|
|
f7eef9dd0c | ||
|
|
7d74ae5d6c | ||
|
|
61841d1ed5 | ||
|
|
7c88a48be4 | ||
|
|
3196260bd4 | ||
|
|
870a44bc7f | ||
|
|
e8201ab66b | ||
|
|
e12feb633f | ||
|
|
304cbde651 | ||
|
|
0feaf0d987 | ||
|
|
d3f36e92a5 | ||
|
|
1467121b16 | ||
|
|
2f0fb2a8ba | ||
|
|
83ed52669d | ||
|
|
5cfe8f1218 | ||
|
|
38506ed372 | ||
|
|
abc97e0545 | ||
|
|
33faacc055 | ||
|
|
8537709e72 | ||
|
|
9fb7808191 |
@@ -3,7 +3,7 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
|
# Every base branch, so stacked PRs and PRs into any base get the same checks.
|
||||||
pull_request:
|
pull_request:
|
||||||
merge_group:
|
merge_group:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|||||||
+19
-8
@@ -1,6 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.14.0-beta.1 - 2026-10-27
|
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||||
|
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
||||||
|
|
||||||
|
Improved:
|
||||||
|
|
||||||
|
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||||
|
|
||||||
|
## 0.14.0 - 2026-10-29
|
||||||
|
|
||||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|
||||||
@@ -10,9 +21,8 @@ Voice settings now let you configure speech providers and download local models
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
|
||||||
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||||
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
- Added Chat display settings that group tool calls or collapse completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||||
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||||
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||||
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||||
@@ -22,10 +32,9 @@ Voice settings now let you configure speech providers and download local models
|
|||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
|
||||||
|
|
||||||
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||||
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||||
|
- Opened subagents in place of the subagent list on phones, with Back returning to the list ([#8093](https://github.com/getpaseo/paseo/pull/8093))
|
||||||
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||||
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||||
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||||
@@ -34,7 +43,6 @@ Voice settings now let you configure speech providers and download local models
|
|||||||
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
||||||
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
||||||
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
||||||
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -51,17 +59,20 @@ Voice settings now let you configure speech providers and download local models
|
|||||||
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||||
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||||
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||||
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
|
||||||
|
- Fixed compact navigation sizes and a cramped Settings detail pane in docked windows 600–719 wide ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
|
||||||
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||||
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||||
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||||
|
- Fixed model discovery errors touching the model picker edges ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
|
||||||
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- Voice mode now defaults to off; enable it in Voice settings
|
- Voice mode now defaults to off; enable it in Voice settings
|
||||||
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
|
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
|
||||||
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
|
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by 0.14.0
|
||||||
|
- Install the optional [Claude Code entrypoint plugin](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env) to launch Claude sessions with `CLAUDE_CODE_ENTRYPOINT=Paseo`, a community workaround for subscription limits draining faster ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
|
||||||
|
|
||||||
## 0.13.2 - 2026-10-26
|
## 0.13.2 - 2026-10-26
|
||||||
|
|
||||||
|
|||||||
+19
-7
@@ -1,6 +1,17 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
## 0.14.0-beta.1 - 2026-10-27
|
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
|
||||||
|
|
||||||
|
新增:
|
||||||
|
|
||||||
|
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||||
|
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
||||||
|
|
||||||
|
改进:
|
||||||
|
|
||||||
|
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||||
|
|
||||||
|
## 0.14.0 - 2026-10-29
|
||||||
|
|
||||||
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||||
|
|
||||||
@@ -11,7 +22,7 @@
|
|||||||
### 新增
|
### 新增
|
||||||
|
|
||||||
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||||
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
- 新增“聊天显示”设置,可将工具调用分组或折叠已完成的对话轮次 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||||
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||||
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||||
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||||
@@ -21,10 +32,9 @@
|
|||||||
|
|
||||||
### 改进
|
### 改进
|
||||||
|
|
||||||
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
|
||||||
|
|
||||||
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||||
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||||
|
- 在手机上从子 Agent 列表打开子 Agent 时直接替换列表,点按“返回”可回到列表 ([#8093](https://github.com/getpaseo/paseo/pull/8093))
|
||||||
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||||
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||||
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||||
@@ -33,7 +43,6 @@
|
|||||||
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
||||||
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
||||||
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
||||||
- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
|
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|
||||||
@@ -50,17 +59,20 @@
|
|||||||
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||||
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||||
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||||
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
|
||||||
|
- 修复宽度 600–719 的停靠布局使用紧凑导航尺寸、设置详情面板过窄的问题 ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
|
||||||
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||||
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||||
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||||
|
- 修复模型发现错误信息紧贴模型选择器边缘的问题 ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
|
||||||
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||||
|
|
||||||
### 说明
|
### 说明
|
||||||
|
|
||||||
- 语音模式默认关闭,可在语音设置中启用
|
- 语音模式默认关闭,可在语音设置中启用
|
||||||
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
|
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
|
||||||
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
|
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 0.14.0 保存的语音凭据和空闲清理设置
|
||||||
|
- 安装可选的 [Claude Code entrypoint 插件](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env),即可使用 `CLAUDE_CODE_ENTRYPOINT=Paseo` 启动 Claude 会话;这是针对订阅额度消耗过快问题的社区变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
|
||||||
|
|
||||||
## 0.13.2 - 2026-10-26
|
## 0.13.2 - 2026-10-26
|
||||||
|
|
||||||
|
|||||||
@@ -117,10 +117,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
|||||||
|
|
||||||
## Release branches
|
## Release branches
|
||||||
|
|
||||||
When the user says "this goes to next", create or
|
Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each
|
||||||
retarget the PR to `next` and preserve that destination through delivery. Follow
|
release builds in a short-lived release workspace from `origin/main` or a release tag; see
|
||||||
[release branch discipline](docs/release.md#release-branch-discipline) for creating
|
[release base and source](docs/release.md#release-base-and-source).
|
||||||
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
|
|
||||||
|
|
||||||
## Critical rules
|
## Critical rules
|
||||||
|
|
||||||
|
|||||||
@@ -95,4 +95,8 @@ Callout crops are in native capture pixels. To find them, overlay a 100 px grid
|
|||||||
|
|
||||||
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
|
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
|
||||||
|
|
||||||
|
## Publish
|
||||||
|
|
||||||
|
The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports.
|
||||||
|
|
||||||
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
|
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
|
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
|
||||||
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
@@ -23,6 +23,7 @@ const root = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const repo = path.resolve(root, "../..");
|
const repo = path.resolve(root, "../..");
|
||||||
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
|
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
|
||||||
const output = path.join(root, "output");
|
const output = path.join(root, "output");
|
||||||
|
const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images");
|
||||||
|
|
||||||
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
|
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
|
||||||
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
|
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
|
||||||
@@ -116,6 +117,7 @@ const STORES = {
|
|||||||
},
|
},
|
||||||
google: {
|
google: {
|
||||||
device: "iphone",
|
device: "iphone",
|
||||||
|
androidScreenshots: "phoneScreenshots",
|
||||||
width: 1440,
|
width: 1440,
|
||||||
height: 2560,
|
height: 2560,
|
||||||
textTop: 150,
|
textTop: 150,
|
||||||
@@ -134,6 +136,7 @@ const STORES = {
|
|||||||
},
|
},
|
||||||
"google-tablet": {
|
"google-tablet": {
|
||||||
device: "ipad",
|
device: "ipad",
|
||||||
|
androidScreenshots: "tenInchScreenshots",
|
||||||
width: 1800,
|
width: 1800,
|
||||||
height: 3200,
|
height: 3200,
|
||||||
textTop: 200,
|
textTop: 200,
|
||||||
@@ -352,6 +355,18 @@ async function expectLines(view, selector, expected, label) {
|
|||||||
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
|
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Google Play and F-Droid read their screenshots from the Android metadata,
|
||||||
|
// which holds links to these renders. Rewriting the links on every render keeps
|
||||||
|
// added, removed and renamed panels in step with the App Store.
|
||||||
|
async function linkAndroidScreenshots(folder, files) {
|
||||||
|
const dir = path.join(androidImages, folder);
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
for (const [index, file] of files.entries()) {
|
||||||
|
await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function renderPreview(rows) {
|
async function renderPreview(rows) {
|
||||||
const tile = 440;
|
const tile = 440;
|
||||||
const gap = 40;
|
const gap = 40;
|
||||||
@@ -387,7 +402,9 @@ try {
|
|||||||
const marks = await loadAgentMarks(work);
|
const marks = await loadAgentMarks(work);
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (const [name, store] of Object.entries(STORES)) {
|
for (const [name, store] of Object.entries(STORES)) {
|
||||||
rows.push(await renderStore(browser, name, store, font, marks, work));
|
const files = await renderStore(browser, name, store, font, marks, work);
|
||||||
|
if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files);
|
||||||
|
rows.push(files);
|
||||||
}
|
}
|
||||||
await renderPreview(rows);
|
await renderPreview(rows);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+24
-2
@@ -12,6 +12,26 @@ initializing → idle → running → idle (or error → closed)
|
|||||||
|
|
||||||
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||||
|
|
||||||
|
## Daemon restart recovery
|
||||||
|
|
||||||
|
Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon
|
||||||
|
reopens the saved provider conversation and settings, then sends `Resume working.`. Change
|
||||||
|
`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents.
|
||||||
|
Disabling recovery consumes pending intent; enabling it later does not revive old work.
|
||||||
|
|
||||||
|
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
|
||||||
|
agents or workspaces, private helpers and Hub-owned executions retain their own lifecycle.
|
||||||
|
Explicit creation environment overrides are saved with the agent and reapplied when reopening.
|
||||||
|
|
||||||
|
Startup chooses candidates before anything else reads or repairs persisted running state, then
|
||||||
|
opens connections and resumes every candidate in the background. A provider that is slow to reopen
|
||||||
|
holds back only its own agent; a failed reopen is logged. Graceful shutdown records intent before
|
||||||
|
closing runtimes.
|
||||||
|
|
||||||
|
This is best-effort continuation. A crash between saving intent and dispatching a prompt can lose
|
||||||
|
or repeat a continuation. Provider resume owns conversation restoration; there is no recovery retry
|
||||||
|
loop or exactly-once turn guarantee.
|
||||||
|
|
||||||
## Runtime residency
|
## Runtime residency
|
||||||
|
|
||||||
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
|
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
|
||||||
@@ -139,8 +159,10 @@ caller, child, and host into one watch.
|
|||||||
|
|
||||||
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
|
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
|
||||||
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
|
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
|
||||||
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
|
the child's run has settled; the parent decides whether its task is complete. A child chosen for restart
|
||||||
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
|
recovery is observed only after its resume was sent, so the parent never hears that it was closed
|
||||||
|
in between. Archived or deleted parents suppress delivery;
|
||||||
|
parent-owned creation watches also stop
|
||||||
reporting after detachment.
|
reporting after detachment.
|
||||||
|
|
||||||
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
|
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
|
||||||
|
|||||||
+4
-1
@@ -215,7 +215,8 @@ fastlane/metadata/android/
|
|||||||
│ ├── images/
|
│ ├── images/
|
||||||
│ │ ├── icon.png (512x512)
|
│ │ ├── icon.png (512x512)
|
||||||
│ │ ├── featureGraphic.png (1024x500)
|
│ │ ├── featureGraphic.png (1024x500)
|
||||||
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
|
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
||||||
|
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
||||||
│ └── changelogs/ (generated — see below)
|
│ └── changelogs/ (generated — see below)
|
||||||
├── ja/
|
├── ja/
|
||||||
└── zh-CN/
|
└── zh-CN/
|
||||||
@@ -223,6 +224,8 @@ fastlane/metadata/android/
|
|||||||
|
|
||||||
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
||||||
|
|
||||||
|
Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots).
|
||||||
|
|
||||||
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
||||||
|
|
||||||
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
||||||
|
|||||||
+1
-1
@@ -203,7 +203,7 @@ Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under th
|
|||||||
|
|
||||||
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||||
|
|
||||||
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Native apps and every compact sidebar show only callouts registered with `native: true`; leave it unset for callouts about the desktop app. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||||
|
|
||||||
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
|
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
|
||||||
|
|
||||||
|
|||||||
+9
-7
@@ -200,13 +200,15 @@ its own.
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
|
- **Web enters without Reanimated.** For a custom keyframe, Reanimated's web path snapshots the
|
||||||
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
|
surface's rect when the animation ends and writes it back as inline top/left/width/height 750ms
|
||||||
identity changes — a pushed page taller than the one it replaced is clipped without that.
|
after the start. Whatever moved or resized the surface in between was undone: content that
|
||||||
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
|
grew once its data arrived left the menu clipped, or hanging over its anchor, until it moved
|
||||||
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
|
again. `AnchoredSurface` plays the same keyframes through the Web Animations API on web, which
|
||||||
entering animation never ends at the off-screen measuring position; on a slow machine that
|
leaves no inline style; native keeps the Reanimated `Keyframe`.
|
||||||
write moved the open menu back off-screen.
|
- **Animate only once placed.** `AnchoredSurface` remounts the surface when its position resolves,
|
||||||
|
so the entering animation runs at the final position rather than ending unseen at the
|
||||||
|
off-screen measuring position.
|
||||||
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
||||||
rarely the height of the page before it. A fill page is the one exception.
|
rarely the height of the page before it. A fill page is the one exception.
|
||||||
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
||||||
|
|||||||
@@ -447,6 +447,12 @@ owns payloads, ordering, failure behavior, and examples. `agent.create.before` e
|
|||||||
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
|
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
|
||||||
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
|
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
|
||||||
|
|
||||||
|
Worktree placement is resolved before Git source planning, so a hook can change the branch and base
|
||||||
|
without undoing fetched refs. Restores use the recorded destination and bypass creation hooks.
|
||||||
|
Workspace records decide ownership, including explicit non-ownership after recovery. Without a record,
|
||||||
|
ownership checks prefer the marker-verified worktree root over the legacy
|
||||||
|
path shape: a plugin can place a worktree several directories below the default worktrees root.
|
||||||
|
|
||||||
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
||||||
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
|
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
|
||||||
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||||
|
|||||||
+147
-74
@@ -6,10 +6,10 @@ All workspaces share one version and release together.
|
|||||||
|
|
||||||
A release has exactly two steps. The agent does the first, the user authorizes the second.
|
A release has exactly two steps. The agent does the first, the user authorizes the second.
|
||||||
|
|
||||||
**Preparation** (local, reversible — agent does this):
|
**Preparation** (reversible — agent does this):
|
||||||
|
|
||||||
- format, lint, typecheck all green
|
- format, lint, typecheck all green
|
||||||
- resolve the release source to one commit and confirm that commit's existing CI is green
|
- resolve the [release base](#release-base-and-source), get the [release inventory](#release-inventory) approved when one applies, and confirm CI for the resulting release source is green
|
||||||
- classify the diff from the previous stable to the release source as patch or minor, then show the
|
- classify the diff from the previous stable to the release source as patch or minor, then show the
|
||||||
target version and rationale to the user
|
target version and rationale to the user
|
||||||
- draft the changelog, show it to the user, wait for review
|
- draft the changelog, show it to the user, wait for review
|
||||||
@@ -17,9 +17,10 @@ A release has exactly two steps. The agent does the first, the user authorizes t
|
|||||||
|
|
||||||
**Go-ahead** (user says "go ahead"):
|
**Go-ahead** (user says "go ahead"):
|
||||||
|
|
||||||
- commit the approved release inputs locally
|
- commit the approved release inputs locally on the release branch
|
||||||
- run the release, which publishes npm and pushes the prepared branch and tag
|
- run the release, which publishes npm and pushes the release branch and tag
|
||||||
- create the release heartbeat immediately and babysit it to completion
|
- create the release heartbeat immediately and babysit it to completion
|
||||||
|
- [reconcile `main`](#reconcile-main) with what shipped
|
||||||
|
|
||||||
Rules that apply to both steps:
|
Rules that apply to both steps:
|
||||||
|
|
||||||
@@ -27,56 +28,128 @@ Rules that apply to both steps:
|
|||||||
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
|
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
|
||||||
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
|
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
|
||||||
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
||||||
|
- An approval the user already gave covers what it names, such as an explicit PR selection or authorization to release autonomously. Do not ask for it again.
|
||||||
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
|
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
|
||||||
|
|
||||||
## Release source and CI
|
## Main stays open
|
||||||
|
|
||||||
The default release source is `origin/main`. Fetch `origin`, then record the
|
Merge normal PRs into `main` at any time, including while a beta soaks or a release is being
|
||||||
resolved commit. The default release checkout is a clean local `main` whose
|
finalized. Each release builds in a short-lived release workspace, and its published tag records
|
||||||
`HEAD` equals `origin/main`.
|
the shipped source.
|
||||||
|
|
||||||
An explicit user instruction can select another ref, such as a hotfix commit or
|
## Choosing a path
|
||||||
tag. Resolve that ref once and apply every source, diff, and CI check to that
|
|
||||||
commit instead of `origin/main`.
|
|
||||||
|
|
||||||
Before making release-preparation commits, confirm the existing CI run for the
|
The user decides when the changes on `main` warrant a release and how much risk it takes:
|
||||||
resolved commit is green. Pending CI is watched to completion. Release
|
|
||||||
preparation then stays local through the changelog, any explicitly requested ACP
|
|
||||||
catalog update, lockfile preparation, and the version commit. After approval,
|
|
||||||
commit the prepared inputs locally and run the release command. Its branch and
|
|
||||||
tag push is the one remote release batch and starts CI for the complete release
|
|
||||||
commit.
|
|
||||||
|
|
||||||
## Release branch discipline
|
1. **Direct stable release** for a low-risk batch of polish and bug fixes. Stable desktop updates
|
||||||
|
already ramp over 36 hours (see [Staged rollout](#staged-rollout-stable-channel)), and store
|
||||||
|
review often takes about a day, with no guaranteed timing.
|
||||||
|
2. **Beta flow**, the default for changes that need testing. 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`.
|
||||||
|
|
||||||
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
|
A beta follows these defaults, which the user can explicitly override:
|
||||||
release. This applies to both beta and stable releases.
|
|
||||||
|
|
||||||
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
|
- Soak a beta at least 24 hours before promotion. Soak longer for risky or breaking changes, or
|
||||||
- "This goes to next" means create the PR against `next` or retarget an existing
|
while feedback is open. Changes added in a later beta get their own soak; earlier soak time does
|
||||||
PR, and keep that destination through delivery.
|
not count for them.
|
||||||
- Keep `next` current by merging `origin/main` into it as release fixes land.
|
- A follow-up beta carries regressions, fixes, and polish for what that beta delivers. Unrelated
|
||||||
Avoid rebasing this shared branch because agents and open PRs depend on its history.
|
work on `main` and pre-existing bugs wait for a later release unless the user explicitly selects
|
||||||
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
|
them.
|
||||||
Pass CI and merge without squashing away the individual PR commits needed for
|
- Promotion ships the tested beta's product content unchanged and does not pull from `main`.
|
||||||
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
|
Adding a product change needs the user's explicit selection and a new beta, which soaks again.
|
||||||
`next`. Create it fresh when needed again.
|
- During a soak, ship urgent stable regressions and compatibility or provider model updates as a
|
||||||
|
[stable hotfix](#release-base-and-source) from the latest stable tag. The beta keeps its own
|
||||||
|
pace.
|
||||||
|
|
||||||
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
|
npm dist-tags are independent pointers:
|
||||||
push runs target only `main`, and GitHub permits only squash merges. Enable
|
|
||||||
required-check protection for `next`, CI on its pushes, and merge commits for the
|
|
||||||
integration PR. Handle PR
|
|
||||||
base changes (`edited` events) so retargeting runs checks against the new base;
|
|
||||||
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
|
|
||||||
|
|
||||||
### Hotfix from a release tag
|
- A beta release publishes with `--tag beta` and moves only `beta`; `latest` remains on the
|
||||||
|
newest stable.
|
||||||
|
- A stable release publishes to `latest`. Move `beta` to it as well when the stable version is
|
||||||
|
newer than the version `beta` points to, which covers promotions and direct stable releases, so
|
||||||
|
`@getpaseo/cli@beta` users never sit behind stable. A stable hotfix on an older line leaves
|
||||||
|
`beta` on the active newer beta.
|
||||||
|
|
||||||
If `main` contains changes you do not want to release, branch from the affected
|
## Release base and source
|
||||||
release tag and cherry-pick only the required fixes. Run CI on that branch, then
|
|
||||||
use the normal release flow with it as the explicit source, choosing a new patch
|
The release base is the tag or commit a release workspace starts from. The release source is the
|
||||||
or beta version. Ensure the fixes and changelog also reach `main` and any active
|
base plus any approved cherry-picks, and every source, diff, and CI check uses it.
|
||||||
`next`, preserving newer development and version changes there. This is a
|
|
||||||
short-lived hotfix branch, not another maintained release track.
|
| Release | Base | Changes on top |
|
||||||
|
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------- |
|
||||||
|
| Starting release: first beta of a line, or direct stable | `origin/main`, captured once | None |
|
||||||
|
| Follow-up beta | The selected published beta tag, normally the series' latest | Approved commits from `main` |
|
||||||
|
| Promotion | The beta tag being promoted | None by default |
|
||||||
|
| Stable hotfix | The latest appropriate stable tag | Approved commits from `main` |
|
||||||
|
|
||||||
|
An explicit user instruction can select another base.
|
||||||
|
|
||||||
|
1. Fetch `origin` with tags and resolve the base to one commit.
|
||||||
|
2. Create the release workspace through Paseo MCP on a new branch from the base, named
|
||||||
|
`release/vX.Y.Z` or `release/vX.Y.Z-beta.N` for the version being cut. Run the release from
|
||||||
|
this workspace: `release:push` pushes `HEAD` to the current branch.
|
||||||
|
3. Cherry-pick the approved commits in their `main` order with `git cherry-pick -x`, so each
|
||||||
|
commit records the `main` commit it came from. Include the prerequisites the inventory listed.
|
||||||
|
Resolve conflicts within the approved changes and report how. A resolution that needs new
|
||||||
|
behavior is a scope addition: surface it for selection.
|
||||||
|
4. Confirm CI for the release source. When the source is a commit whose CI already passed (the
|
||||||
|
captured `origin/main`, or the beta tag being promoted), use that run and watch pending CI to
|
||||||
|
completion. Otherwise push the release branch and run `gh workflow run ci.yml --ref <branch>`,
|
||||||
|
because CI push runs target only `main`.
|
||||||
|
5. Keep the changelog, any explicitly requested ACP catalog update, lockfile preparation, and the
|
||||||
|
version commit local until the go-ahead. The release command pushes the release branch and tag
|
||||||
|
as one batch, and the tag push starts the release workflows. Then run
|
||||||
|
`gh workflow run ci.yml --ref <branch>` so CI covers the complete release commit.
|
||||||
|
6. [Reconcile `main`](#reconcile-main).
|
||||||
|
|
||||||
|
A stable hotfix can take the version a patch beta line would promote to, such as `0.13.3`
|
||||||
|
shipping while `0.13.3-beta.2` soaks. Never republish or reuse a shipped version. Continue the line
|
||||||
|
as the next unused patch beta, `0.13.4-beta.1`, from the tested beta tag. No `version:all:*` mode
|
||||||
|
computes that from a prerelease, so follow the [manual steps](#manual-step-by-step) with
|
||||||
|
`npm version 0.13.4-beta.1 --include-workspace-root --message "chore(release): cut %s"` and
|
||||||
|
`npm run release:publish:beta`. Record the tested beta tag as its source so its soak still counts
|
||||||
|
for the unchanged content.
|
||||||
|
|
||||||
|
## Release inventory
|
||||||
|
|
||||||
|
When the user asks for a follow-up beta or a promotion, list what `main` has that the base does
|
||||||
|
not, then get the batch approved. There is no release label or queue.
|
||||||
|
|
||||||
|
1. Find the commits on `origin/main` whose patches are not in the base tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --cherry-pick --right-only --no-merges --format='%h %s' <base-tag>...origin/main
|
||||||
|
```
|
||||||
|
|
||||||
|
Patch equivalence recognizes commits already cherry-picked into the tag, so trust the tag
|
||||||
|
content over PR state or branch names.
|
||||||
|
|
||||||
|
2. Present a short include/defer list. Link each PR and give one plain reason. List separately the
|
||||||
|
prerequisites an included PR depends on and anything that adds scope beyond what the beta
|
||||||
|
delivers. For each fix that shipped in a stable hotfix after the beta's base, confirm the beta
|
||||||
|
preserves its behavior, directly or through a superseding change. List the missing ones for
|
||||||
|
explicit selection; adding one to a promotion needs a new beta.
|
||||||
|
3. Wait for the user to approve the batch, unless an explicit selection or autonomous
|
||||||
|
authorization they already gave covers it.
|
||||||
|
|
||||||
|
For a promotion, every item defaults to defer.
|
||||||
|
|
||||||
|
## Reconcile main
|
||||||
|
|
||||||
|
After the release push, open a PR into `main` that brings in what shipped and keeps newer
|
||||||
|
development on `main`:
|
||||||
|
|
||||||
|
- The shipped `CHANGELOG.md` and `CHANGELOG.zh-CN.md` entries, exactly as tagged. A beta or
|
||||||
|
promotion entry replaces the series entry on `main`. A stable hotfix on an older line goes
|
||||||
|
below any newer entries.
|
||||||
|
- The shipped version in every `package.json` and `package-lock.json`, unless `main` already
|
||||||
|
carries a newer version, plus the F-Droid changelog files a stable release generates.
|
||||||
|
- No claims about unshipped work: a shipped entry describes only the tagged content, and notes
|
||||||
|
`main` holds for changes that have not shipped stay out of it.
|
||||||
|
|
||||||
|
Delete the release branch once the PR merges. The tag is the record of shipped source; Paseo
|
||||||
|
maintains no release track besides `main`.
|
||||||
|
|
||||||
## ACP catalog updates
|
## ACP catalog updates
|
||||||
|
|
||||||
@@ -91,21 +164,6 @@ ACP catalog work enters a release through an explicit user request:
|
|||||||
The release authorization covers the requested ACP commit. It ships in the same
|
The release authorization covers the requested ACP commit. It ships in the same
|
||||||
release push as the changelog and version commit.
|
release push as the changelog and version commit.
|
||||||
|
|
||||||
## Two 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`).
|
|
||||||
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
|
|
||||||
pointers. The npm invariant is:
|
|
||||||
|
|
||||||
- A beta release moves only `beta`; `latest` remains on the newest stable.
|
|
||||||
- A stable release moves both `latest` and `beta` to that stable version. This
|
|
||||||
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
|
|
||||||
a beta is promoted or superseded by a direct stable release.
|
|
||||||
|
|
||||||
## Release version decision
|
## Release version decision
|
||||||
|
|
||||||
Every fresh release starts by classifying the full diff from the previous
|
Every fresh release starts by classifying the full diff from the previous
|
||||||
@@ -133,7 +191,7 @@ as described in **Fixing a failed release build**.
|
|||||||
|
|
||||||
Before running any stable release command:
|
Before running any stable release command:
|
||||||
|
|
||||||
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
|
- Make sure the release source passed CI, the approved release inputs are committed locally on the release branch, and the working tree is clean.
|
||||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
||||||
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
|
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
|
||||||
|
|
||||||
@@ -145,9 +203,9 @@ npm run release:minor
|
|||||||
|
|
||||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
|
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
|
||||||
|
|
||||||
After the stable release succeeds, move npm's `beta` pointer to the new stable
|
After the stable release succeeds, check `npm view @getpaseo/cli dist-tags`. When the new stable
|
||||||
version for every published package. This changes dist-tags only; do not
|
is newer than `beta`, move `beta` to it for every published package. This changes dist-tags only;
|
||||||
republish the packages:
|
do not republish the packages:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PASEO_VERSION=$(node -p "require('./package.json').version")
|
PASEO_VERSION=$(node -p "require('./package.json').version")
|
||||||
@@ -156,8 +214,8 @@ for package in highlight relay protocol client plugin server cli; do
|
|||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
|
Verify `latest` resolves to `PASEO_VERSION`, and `beta` resolves to `PASEO_VERSION` or the
|
||||||
stable release complete.
|
active newer beta, before considering the stable release complete.
|
||||||
|
|
||||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||||
|
|
||||||
@@ -175,7 +233,7 @@ npm run version:all:patch
|
|||||||
npm run version:all:minor
|
npm run version:all:minor
|
||||||
npm run release:publish # Publish to npm
|
npm run release:publish # Publish to npm
|
||||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||||
# Then move npm's beta dist-tag to this stable version using the command above.
|
# Then move npm's beta dist-tag to this stable version when it is newer than beta (see above).
|
||||||
```
|
```
|
||||||
|
|
||||||
## Beta flow
|
## Beta flow
|
||||||
@@ -305,8 +363,8 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
|
|||||||
|
|
||||||
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
||||||
|
|
||||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
|
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials). A Fastlane lane then replaces the store screenshots.
|
||||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
|
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane uploads the store screenshots and submits that same build for App Store review.
|
||||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
||||||
|
|
||||||
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
|
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
|
||||||
@@ -317,6 +375,17 @@ Stable iOS releases use one binary for two Apple review paths: Beta App Review f
|
|||||||
|
|
||||||
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
|
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
|
||||||
|
|
||||||
|
### Store screenshots
|
||||||
|
|
||||||
|
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
|
||||||
|
|
||||||
|
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
|
||||||
|
- When `submit_review` fails with `appStoreVersions … is not in valid state`, the reason is in the API error's `meta.associatedErrors`, which fastlane does not print. Read it with a `POST /v1/reviewSubmissionItems` against the version. A required age-rating question that Apple added, such as `socialMedia`, fails this way; answer it under App Information > Age Rating in App Store Connect, then resubmit.
|
||||||
|
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the `en-US` phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. The lane stages only `en-US`: supply uploads every locale folder in one edit, and Play rejects F-Droid locales such as `ja`, which fails the whole edit. `Upload Google Play screenshots` reruns the step for the current release without a rebuild.
|
||||||
|
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
|
||||||
|
|
||||||
|
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
|
||||||
|
|
||||||
### Watching mobile builds from the terminal
|
### Watching mobile builds from the terminal
|
||||||
|
|
||||||
Use the EAS CLI from `packages/app/`:
|
Use the EAS CLI from `packages/app/`:
|
||||||
@@ -359,6 +428,7 @@ For the `Release Mobile` EAS workflow, these jobs must pass:
|
|||||||
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
||||||
- `build_android` — Android store binary built
|
- `build_android` — Android store binary built
|
||||||
- `submit_android` — Android binary submitted to the Play Store
|
- `submit_android` — Android binary submitted to the Play Store
|
||||||
|
- `upload_android_screenshots` — Google Play screenshots replaced from the repo
|
||||||
|
|
||||||
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||||
|
|
||||||
@@ -477,7 +547,7 @@ git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
|||||||
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
This ensures the checkout ref matches the code you want built, with the fix included.
|
||||||
|
|
||||||
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
|
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
|
||||||
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||||
@@ -620,7 +690,7 @@ Rules:
|
|||||||
|
|
||||||
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
|
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
|
||||||
|
|
||||||
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
|
When listing PR numbers, `git log --cherry-pick --right-only --no-merges --format='%H %s' v<previous>...<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects and skips commits the previous stable already shipped as cherry-picks.
|
||||||
|
|
||||||
## Changelog ordering
|
## Changelog ordering
|
||||||
|
|
||||||
@@ -652,11 +722,11 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
|||||||
|
|
||||||
### Beta release
|
### Beta release
|
||||||
|
|
||||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source 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
|
||||||
- [ ] 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 with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) 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 with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) 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
|
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release 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
|
||||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||||
@@ -667,22 +737,24 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
|||||||
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
|
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
|
||||||
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
|
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
|
||||||
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
|
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
|
||||||
|
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
|
||||||
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||||
|
|
||||||
### Stable release (or promotion)
|
### Stable release (or promotion)
|
||||||
|
|
||||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
|
||||||
|
- [ ] A promotion ships the tested beta's product content after its soak, unless the user explicitly overrode either default
|
||||||
- [ ] 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 renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
|
- [ ] 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. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
|
||||||
- [ ] 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
|
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release 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
|
||||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||||
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
- [ ] `latest` resolves to the new stable, and `beta` resolves to it too unless an active newer beta holds `beta`
|
||||||
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||||
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
||||||
@@ -696,4 +768,5 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
|||||||
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
||||||
- [ ] EAS Android `build_android` completes for the same tag
|
- [ ] EAS Android `build_android` completes for the same tag
|
||||||
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
||||||
|
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
|
||||||
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ then replaces the previous tool state or merges into the current text item. Inte
|
|||||||
are never retained for history or catch-up. Provider history is the durable transcript authority
|
are never retained for history or catch-up. Provider history is the durable transcript authority
|
||||||
and rebuilds the projection when an agent resumes.
|
and rebuilds the projection when an agent resumes.
|
||||||
|
|
||||||
|
Pagination bounds each read, not total resident history. Active and idle agents retain their
|
||||||
|
projected transcript; archive releases it and later history reads rebuild it from the provider.
|
||||||
|
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
|
||||||
|
must be released after their deltas have been consumed.
|
||||||
|
|
||||||
The invariants are:
|
The invariants are:
|
||||||
|
|
||||||
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
||||||
|
|||||||
@@ -358,6 +358,14 @@ export to `src`, but browser and Electron Metro resolve its `browser` export to
|
|||||||
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
|
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
|
||||||
Electron running the old code.
|
Electron running the old code.
|
||||||
|
|
||||||
|
The adaptive-theme patch keeps `colorScheme` reads off the media-query lists
|
||||||
|
owned by Unistyles' change listeners. Chromium defers their events while a window
|
||||||
|
is hidden; reading `matches` on those same lists can consume the pending change.
|
||||||
|
CSS variables then follow the OS theme while `withUnistyles` props retain the old
|
||||||
|
colors. Keep this patch until upstream reads the scheme through separate lists.
|
||||||
|
The desktop browser E2E checks both transition directions in an isolated Electron
|
||||||
|
process, because Playwright's focus emulation prevents real hidden-page behavior.
|
||||||
|
|
||||||
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
|
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
|
||||||
`patches/` is inert unless that script knows which installed package activates
|
`patches/` is inert unless that script knows which installed package activates
|
||||||
it.
|
it.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|
||||||
|
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||||
|
|
||||||
|
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Let plugins give tools to specific agents
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|
||||||
|
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||||
|
|
||||||
|
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Let plugins give tools to specific agents
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|
||||||
|
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||||
|
|
||||||
|
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Let plugins give tools to specific agents
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||||
|
|
||||||
|
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||||
|
|
||||||
|
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||||
|
|
||||||
|
Added
|
||||||
|
- Let plugins give tools to specific agents
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -1 +1 @@
|
|||||||
../../../../../../packages/website/public/phone-1.png
|
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
|
||||||
@@ -1 +1 @@
|
|||||||
../../../../../../packages/website/public/phone-2.png
|
../../../../../../assets/store-screenshots/output/google/02-chat.png
|
||||||
@@ -1 +1 @@
|
|||||||
../../../../../../packages/website/public/phone-3.png
|
../../../../../../assets/store-screenshots/output/google/03-plugins.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google/04-review.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google/05-terminal.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
|
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
|
||||||
|
|||||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -37590,7 +37590,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -39793,12 +39793,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.14.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@getpaseo/server": "0.14.0-beta.1",
|
"@getpaseo/server": "0.14.0",
|
||||||
"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",
|
||||||
@@ -40045,10 +40045,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@getpaseo/relay": "0.14.0-beta.1",
|
"@getpaseo/relay": "0.14.0",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -40060,7 +40060,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -40304,7 +40304,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.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -40350,7 +40350,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -40379,15 +40379,15 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^1.4.0",
|
"@agentclientprotocol/sdk": "^1.4.0",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.14.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -40397,8 +40397,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.14.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"react": ">=19.1.0 <20",
|
"react": ">=19.1.0 <20",
|
||||||
"react-native": ">=0.81.5",
|
"react-native": ">=0.81.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -40420,7 +40420,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -40447,7 +40447,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ciphers": "^2.4.0",
|
"@noble/ciphers": "^2.4.0",
|
||||||
"@noble/curves": "^2.4.0",
|
"@noble/curves": "^2.4.0",
|
||||||
@@ -40668,16 +40668,16 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"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.14.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/highlight": "0.14.0-beta.1",
|
"@getpaseo/highlight": "0.14.0",
|
||||||
"@getpaseo/plugin": "0.14.0-beta.1",
|
"@getpaseo/plugin": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@getpaseo/relay": "0.14.0-beta.1",
|
"@getpaseo/relay": "0.14.0",
|
||||||
"@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",
|
||||||
@@ -41598,7 +41598,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"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",
|
||||||
@@ -43208,7 +43208,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"name": "@getpaseo/builtin-plugins",
|
"name": "@getpaseo/builtin-plugins",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"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.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"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": [
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ jobs:
|
|||||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||||
profile: production
|
profile: production
|
||||||
|
|
||||||
|
upload_android_screenshots:
|
||||||
|
name: Upload Google Play screenshots
|
||||||
|
needs: [build_android, submit_android]
|
||||||
|
environment: production
|
||||||
|
runs_on: macos-medium
|
||||||
|
steps:
|
||||||
|
- uses: eas/checkout
|
||||||
|
- name: Install fastlane
|
||||||
|
run: bundle install
|
||||||
|
- name: Upload screenshots
|
||||||
|
run: |
|
||||||
|
export ANDROID_VERSION_CODE="${{ needs.build_android.outputs.app_build_version }}"
|
||||||
|
bundle exec fastlane android upload_screenshots
|
||||||
|
|
||||||
submit_ios_for_review:
|
submit_ios_for_review:
|
||||||
name: Submit iOS for App Store review
|
name: Submit iOS for App Store review
|
||||||
needs: [build_ios, distribute_ios]
|
needs: [build_ios, distribute_ios]
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Upload Google Play screenshots
|
||||||
|
|
||||||
|
# Standalone re-trigger for the release workflow's Google Play screenshot step.
|
||||||
|
# Replaces the en-US phone and tablet screenshots with the committed renders,
|
||||||
|
# without rebuilding or resubmitting the app.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
android_version_code:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: "versionCode of the production release to attach the change to, e.g. 14000. Leave empty when the production track has a single release."
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upload_android_screenshots:
|
||||||
|
name: Upload Google Play screenshots
|
||||||
|
environment: production
|
||||||
|
runs_on: macos-medium
|
||||||
|
steps:
|
||||||
|
- uses: eas/checkout
|
||||||
|
- name: Install fastlane
|
||||||
|
run: bundle install
|
||||||
|
- name: Upload screenshots
|
||||||
|
run: |
|
||||||
|
export ANDROID_VERSION_CODE="${{ inputs.android_version_code }}"
|
||||||
|
bundle exec fastlane android upload_screenshots
|
||||||
@@ -102,7 +102,7 @@ export default {
|
|||||||
name: variant.name,
|
name: variant.name,
|
||||||
slug: "voice-mobile",
|
slug: "voice-mobile",
|
||||||
version: nativeReleaseVersion.appVersion,
|
version: nativeReleaseVersion.appVersion,
|
||||||
orientation: "portrait",
|
orientation: "default",
|
||||||
icon: "./assets/images/icon.png",
|
icon: "./assets/images/icon.png",
|
||||||
scheme: "paseo",
|
scheme: "paseo",
|
||||||
userInterfaceStyle: "automatic",
|
userInterfaceStyle: "automatic",
|
||||||
|
|||||||
@@ -59,3 +59,38 @@ describe("Android app config", () => {
|
|||||||
30_000,
|
30_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("follows system rotation on Android", async () => {
|
||||||
|
const manifest = await resolveAndroidManifest();
|
||||||
|
const activity = manifest.application[0].activity.find(
|
||||||
|
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
|
||||||
|
);
|
||||||
|
expect(activity.$["android:screenOrientation"]).toBe("unspecified");
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("allows all orientations on iPhone and iPad", async () => {
|
||||||
|
const { mkdtemp, rm } = await import("node:fs/promises");
|
||||||
|
const { tmpdir } = await import("node:os");
|
||||||
|
const { join } = await import("node:path");
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "paseo-ios-config-"));
|
||||||
|
try {
|
||||||
|
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["ios"] });
|
||||||
|
await compileModsAsync(exp, {
|
||||||
|
projectRoot: root,
|
||||||
|
introspect: true,
|
||||||
|
platforms: ["ios"],
|
||||||
|
assertMissingModProviders: false,
|
||||||
|
});
|
||||||
|
const plist = exp._internal.modResults.ios.infoPlist;
|
||||||
|
const orientations = [
|
||||||
|
"UIInterfaceOrientationPortrait",
|
||||||
|
"UIInterfaceOrientationPortraitUpsideDown",
|
||||||
|
"UIInterfaceOrientationLandscapeLeft",
|
||||||
|
"UIInterfaceOrientationLandscapeRight",
|
||||||
|
];
|
||||||
|
expect(plist.UISupportedInterfaceOrientations).toEqual(orientations);
|
||||||
|
expect(plist["UISupportedInterfaceOrientations~ipad"]).toEqual(orientations);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { expect, metroTest as test } from "../support/fixtures";
|
|
||||||
import { gotoAppShell } from "../support/helpers/app";
|
|
||||||
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
|
||||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
|
||||||
|
|
||||||
// 0.8.0 predates terminalsSurviveRestart and supports daemonSelfUpdate.
|
|
||||||
const PUBLISHED_DAEMON_VERSION = "0.8.0";
|
|
||||||
const COMPACT = { width: 390, height: 844 };
|
|
||||||
|
|
||||||
// Opens the app against a published 0.8.0 daemon, with the developer daemon blocked.
|
|
||||||
async function withOutdatedDaemon(page: Page, run: () => Promise<void>): Promise<void> {
|
|
||||||
const serverId = `srv_daemon_outdated_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
||||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
|
||||||
publishedVersion: PUBLISHED_DAEMON_VERSION,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const host = buildSeededHost({
|
|
||||||
serverId,
|
|
||||||
endpoint: `127.0.0.1:${daemon.port}`,
|
|
||||||
nowIso: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
await page.route(/:6767\b/, (route) => route.abort());
|
|
||||||
await page.routeWebSocket(/:6767\b/, async (webSocket) => {
|
|
||||||
await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." });
|
|
||||||
});
|
|
||||||
await page.addInitScript(
|
|
||||||
({ seededHost }) => {
|
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
|
||||||
},
|
|
||||||
{ seededHost: host },
|
|
||||||
);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await run();
|
|
||||||
} finally {
|
|
||||||
await daemon.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectOutdatedNotice(page: Page): Promise<void> {
|
|
||||||
const callout = page.getByTestId("daemon-outdated-callout").first();
|
|
||||||
await expect(callout).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(callout).toContainText("Daemon out of date");
|
|
||||||
await expect(callout).toContainText(`daemon v${PUBLISHED_DAEMON_VERSION}`);
|
|
||||||
await expect(callout).toContainText("Updating restarts the daemon and closes open terminals.");
|
|
||||||
const update = page.getByTestId("daemon-outdated-callout-update").first();
|
|
||||||
await expect(update).toBeVisible();
|
|
||||||
await expect(update).toBeEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("sidebar shows an update notice when the active host's daemon is older than the app", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(180_000);
|
|
||||||
await withOutdatedDaemon(page, () => expectOutdatedNotice(page));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("compact sidebar shows the update notice, which is a native callout", async ({ page }) => {
|
|
||||||
test.setTimeout(180_000);
|
|
||||||
await page.setViewportSize(COMPACT);
|
|
||||||
await withOutdatedDaemon(page, async () => {
|
|
||||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
|
||||||
await expectOutdatedNotice(page);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -204,19 +204,26 @@ test.describe("Projects settings", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const repo = await createTempGitRepo("projects-settings-empty-");
|
const repo = await createTempGitRepo("projects-settings-empty-");
|
||||||
const client = await connectSeedClient();
|
const client = await connectSeedClient();
|
||||||
let projectId: string | null = null;
|
let projectViewKey: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
|
|
||||||
projectId = await addProjectFromSidebar(page, repo.path);
|
projectViewKey = await addProjectFromSidebar(page, repo.path);
|
||||||
await openProjectSettingsFromSidebar(page, projectId);
|
await openProjectSettingsFromSidebar(page, projectViewKey);
|
||||||
|
|
||||||
await expectProjectSettingsFormVisible(page);
|
await expectProjectSettingsFormVisible(page);
|
||||||
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
|
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
if (projectId) {
|
if (projectViewKey) {
|
||||||
await client.removeProject(projectId).catch(() => undefined);
|
const project = (await client.listProjects()).projects.find(
|
||||||
|
(entry) => entry.projectKey === projectViewKey,
|
||||||
|
);
|
||||||
|
expect(project).toBeDefined();
|
||||||
|
await client.removeProject(project!.projectId);
|
||||||
|
expect(
|
||||||
|
(await client.listProjects()).projects.map((entry) => entry.projectKey),
|
||||||
|
).not.toContain(projectViewKey);
|
||||||
}
|
}
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
await repo.cleanup().catch(() => undefined);
|
await repo.cleanup().catch(() => undefined);
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import {
|
|||||||
addDirectHostFromSettings,
|
addDirectHostFromSettings,
|
||||||
expectHostOnlineWithoutError,
|
expectHostOnlineWithoutError,
|
||||||
expectIdleAgentRelease,
|
expectIdleAgentRelease,
|
||||||
|
editRestartRecoveryPrompt,
|
||||||
|
expectRestartRecoveryEnabled,
|
||||||
|
expectRestartRecoveryPrompt,
|
||||||
|
setRestartRecoveryEnabled,
|
||||||
setIdleAgentRelease,
|
setIdleAgentRelease,
|
||||||
} from "../support/helpers/settings";
|
} from "../support/helpers/settings";
|
||||||
|
|
||||||
@@ -252,3 +256,20 @@ test.describe("Settings host page", () => {
|
|||||||
await expectHostActionCards(page, serverId);
|
await expectHostActionCards(page, serverId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
|
||||||
|
const serverId = getServerId();
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
await openSettingsHost(page, serverId);
|
||||||
|
await openHostSection(page, serverId, "agents");
|
||||||
|
await expectRestartRecoveryEnabled(page, true);
|
||||||
|
await expectRestartRecoveryPrompt(page, "Resume working.");
|
||||||
|
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||||
|
await setRestartRecoveryEnabled(page, false);
|
||||||
|
await page.reload();
|
||||||
|
await expectRestartRecoveryEnabled(page, false);
|
||||||
|
await setRestartRecoveryEnabled(page, true);
|
||||||
|
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||||
|
await editRestartRecoveryPrompt(page, "Resume working.");
|
||||||
|
});
|
||||||
|
|||||||
@@ -415,19 +415,8 @@ test.describe("Settings — compact master-detail", () => {
|
|||||||
const workspace = await withWorkspace({ prefix: "host-picker-settings-back-" });
|
const workspace = await withWorkspace({ prefix: "host-picker-settings-back-" });
|
||||||
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), workspace.workspaceId);
|
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), workspace.workspaceId);
|
||||||
|
|
||||||
const worktreeSetupCallout = page.locator('[data-testid^="worktree-setup-callout-"]');
|
await openWorkspace(page, workspace);
|
||||||
|
|
||||||
await test.step("the wide sidebar shows the worktree setup prompt for this workspace", async () => {
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 });
|
|
||||||
await openWorkspace(page, workspace);
|
|
||||||
await expect(worktreeSetupCallout.first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||||
// The compact sidebar shows only native callouts, so the prompt stays out.
|
|
||||||
await expect(page.getByTestId("sidebar-hosts-trigger")).toBeVisible();
|
|
||||||
await expect(worktreeSetupCallout).toHaveCount(0);
|
|
||||||
await page.getByTestId("sidebar-hosts-trigger").click();
|
await page.getByTestId("sidebar-hosts-trigger").click();
|
||||||
await page.getByRole("button", { name: /Open .* settings/ }).click();
|
await page.getByRole("button", { name: /Open .* settings/ }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -53,11 +53,18 @@ async function quickDragFirstRowAfterSecond(
|
|||||||
await page.mouse.move(source.x, source.y + 7);
|
await page.mouse.move(source.x, source.y + 7);
|
||||||
await expect(trailingScrim).toHaveCount(0);
|
await expect(trailingScrim).toHaveCount(0);
|
||||||
await page.mouse.move(target.x, target.y, { steps: 4 });
|
await page.mouse.move(target.x, target.y, { steps: 4 });
|
||||||
await page.mouse.up();
|
await finishSidebarDrag(page);
|
||||||
|
|
||||||
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
|
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function finishSidebarDrag(page: Page): Promise<void> {
|
||||||
|
await page.mouse.up();
|
||||||
|
// dnd-kit's pointer sensor keeps its document click blocker for 50 ms after drop.
|
||||||
|
// Row order updates before that blocker is removed; wait before the next user action.
|
||||||
|
await page.waitForTimeout(60);
|
||||||
|
}
|
||||||
|
|
||||||
async function dragWithoutReordering(rows: Locator): Promise<void> {
|
async function dragWithoutReordering(rows: Locator): Promise<void> {
|
||||||
const before = await rowTestIds(rows);
|
const before = await rowTestIds(rows);
|
||||||
const source = await visibleBoundingBox(rows.nth(0));
|
const source = await visibleBoundingBox(rows.nth(0));
|
||||||
@@ -66,7 +73,7 @@ async function dragWithoutReordering(rows: Locator): Promise<void> {
|
|||||||
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
|
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
|
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
|
||||||
await page.mouse.up();
|
await finishSidebarDrag(page);
|
||||||
await expect.poll(() => rowTestIds(rows)).toEqual(before);
|
await expect.poll(() => rowTestIds(rows)).toEqual(before);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||||
|
import { createCreationScenario } from "../support/helpers/creation";
|
||||||
|
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
const DRAG_DISTANCE = -40;
|
||||||
|
const TOLERANCE_PX = 2;
|
||||||
|
|
||||||
|
function splitDividers(page: Page): Locator {
|
||||||
|
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dividerX(divider: Locator): Promise<number> {
|
||||||
|
const box = await divider.boundingBox();
|
||||||
|
if (!box) throw new Error("Divider has no bounding box");
|
||||||
|
return box.x;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
|
||||||
|
const page = divider.page();
|
||||||
|
const box = await divider.boundingBox();
|
||||||
|
if (!box) throw new Error("Divider has no bounding box");
|
||||||
|
const x = box.x + box.width / 2;
|
||||||
|
const y = box.y + box.height / 2;
|
||||||
|
await page.mouse.move(x, y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(x + deltaX, y, { steps: 8 });
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise<void> {
|
||||||
|
const page = divider.page();
|
||||||
|
const box = await divider.boundingBox();
|
||||||
|
if (!box) throw new Error("Divider has no bounding box");
|
||||||
|
const x = box.x + box.width / 2;
|
||||||
|
const y = box.y + box.height / 2;
|
||||||
|
const input = await page.context().newCDPSession(page);
|
||||||
|
try {
|
||||||
|
await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
||||||
|
for (let step = 1; step <= 8; step++) {
|
||||||
|
await input.send("Input.dispatchTouchEvent", {
|
||||||
|
type: "touchMove",
|
||||||
|
touchPoints: [{ x: x + (deltaX * step) / 8, y }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||||
|
} finally {
|
||||||
|
await input.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function splitIntoFourPanes(page: Page): Promise<void> {
|
||||||
|
for (const expectedDividers of [1, 2, 3]) {
|
||||||
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
|
await expect(splitDividers(page)).toHaveCount(expectedDividers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectDividersTrackPointer(
|
||||||
|
page: Page,
|
||||||
|
distance = DRAG_DISTANCE,
|
||||||
|
drag = dragDivider,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const index of [0, 1, 2]) {
|
||||||
|
const divider = splitDividers(page).nth(index);
|
||||||
|
const before = await dividerX(divider);
|
||||||
|
await drag(divider, distance);
|
||||||
|
await expect
|
||||||
|
.poll(async () => Math.abs((await dividerX(divider)) - before - distance), {
|
||||||
|
message: `divider ${index + 1} should stay under the pointer`,
|
||||||
|
})
|
||||||
|
.toBeLessThanOrEqual(TOLERANCE_PX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hasTouch of [false, true]) {
|
||||||
|
test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => {
|
||||||
|
test.use({ hasTouch });
|
||||||
|
|
||||||
|
test("a divider moves as far as the pointer in the main row", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1600, height: 900 });
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await clickNewChat(page);
|
||||||
|
await splitIntoFourPanes(page);
|
||||||
|
await expectDividersTrackPointer(
|
||||||
|
page,
|
||||||
|
DRAG_DISTANCE,
|
||||||
|
hasTouch ? dragDividerWithTouch : dragDivider,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dividers track the pointer after app creation, Explorer changes and reload", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1600, height: 900 });
|
||||||
|
const creation = await createCreationScenario(page);
|
||||||
|
try {
|
||||||
|
await creation.openWorkspaceForm("local");
|
||||||
|
await creation.submitPrompt("Reply briefly.", "Create");
|
||||||
|
await creation.expectAssistantReply();
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await splitIntoFourPanes(page);
|
||||||
|
await expectDividersTrackPointer(page);
|
||||||
|
await page
|
||||||
|
.getByTestId("workspace-explorer-toggle")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||||
|
await expectDividersTrackPointer(page, -DRAG_DISTANCE);
|
||||||
|
await page
|
||||||
|
.getByTestId("workspace-explorer-toggle")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
await expect(splitDividers(page)).toHaveCount(3);
|
||||||
|
await expectDividersTrackPointer(page);
|
||||||
|
} finally {
|
||||||
|
await creation.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -717,15 +717,15 @@ test("open usage clocks advance without refreshing or reopening", async ({ page
|
|||||||
await openCompactUsageWithClock(page, now);
|
await openCompactUsageWithClock(page, now);
|
||||||
const card = page.getByTestId("usage-report-login-journey:account");
|
const card = page.getByTestId("usage-report-login-journey:account");
|
||||||
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
|
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
|
||||||
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
|
await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible();
|
||||||
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
|
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
|
||||||
// After two browser minutes the latest displayed tick is one or two minutes old.
|
// After two browser minutes the latest displayed tick is one or two minutes old.
|
||||||
await advanceUsageClock(page, 120_000);
|
await advanceUsageClock(page, 120_000);
|
||||||
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
|
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
|
||||||
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
|
await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible();
|
||||||
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
|
await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible();
|
||||||
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
|
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
|
||||||
/^resets (28|29)m$/,
|
/^resets in (28|29)m\s·\s/,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await login.cleanup();
|
await login.cleanup();
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# Native daemon-outdated notice
|
|
||||||
|
|
||||||
The flow needs the development app connected to an isolated daemon older than the app. Install a published daemon that predates `terminalsSurviveRestart`, start it in its own home, and bundle the app against it. `<checkout>` is the root of this repository; `<port>`, `<metro-port>` and `<udid>` are a free daemon port, a free Metro port and the booted simulator.
|
|
||||||
|
|
||||||
Run this once, in any directory. It installs into `/tmp/old-daemon`, not globally:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm install --prefix /tmp/old-daemon --no-package-lock @getpaseo/server@0.8.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Terminal 1, the old daemon. It runs in the foreground; keep it running:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd /tmp/old-daemon/node_modules/@getpaseo/server
|
|
||||||
PASEO_HOME=/tmp/old-daemon-home PASEO_LISTEN=0.0.0.0:<port> PASEO_RELAY_ENABLED=0 node dist/scripts/supervisor-entrypoint.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Terminal 2, Metro. It runs in the foreground; keep it running:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd <checkout>/packages/app
|
|
||||||
APP_VARIANT=development EXPO_PUBLIC_LOCAL_DAEMON=localhost:<port> npx expo start --port <metro-port> --clear
|
|
||||||
```
|
|
||||||
|
|
||||||
Terminal 3, the flow, while terminals 1 and 2 are still running:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd <checkout>
|
|
||||||
xcrun simctl openurl <udid> "paseo://expo-development-client/?url=http%3A%2F%2F127.0.0.1%3A<metro-port>"
|
|
||||||
maestro --udid <udid> test packages/app/e2e/mobile/agent-device/native-daemon-outdated-notice.ios.yaml --env DAEMON_VERSION=0.8.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Start with the sidebar closed. The flow opens it, then asserts the notice, its daemon version, the terminals line for a daemon without `terminalsSurviveRestart`, and an enabled Update button. Do not tap Update: on 0.8.0 it runs a global `npm install -g @getpaseo/cli@latest`.
|
|
||||||
|
|
||||||
When you are done, stop terminals 1 and 2 and remove `/tmp/old-daemon` and `/tmp/old-daemon-home`.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
appId: sh.paseo.debug
|
|
||||||
---
|
|
||||||
# The app must be connected to a daemon older than the app that predates
|
|
||||||
# terminalsSurviveRestart (0.8.0). See native-daemon-outdated-notice-preconditions.md.
|
|
||||||
- launchApp
|
|
||||||
- runFlow:
|
|
||||||
when:
|
|
||||||
visible: ".*getRegistrationInfoAsync.*"
|
|
||||||
commands:
|
|
||||||
- tapOn: ".*getRegistrationInfoAsync.*"
|
|
||||||
- tapOn: Dismiss
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible: "Open menu"
|
|
||||||
timeout: 60000
|
|
||||||
- tapOn: "Open menu"
|
|
||||||
- extendedWaitUntil:
|
|
||||||
visible:
|
|
||||||
id: daemon-outdated-callout
|
|
||||||
timeout: 30000
|
|
||||||
- assertVisible: "Daemon out of date"
|
|
||||||
- assertVisible: ".*daemon v${DAEMON_VERSION}.*"
|
|
||||||
- assertVisible: "Updating restarts the daemon and closes open terminals."
|
|
||||||
- assertVisible:
|
|
||||||
id: daemon-outdated-callout-update
|
|
||||||
enabled: true
|
|
||||||
- takeScreenshot: native-daemon-outdated-notice
|
|
||||||
@@ -560,3 +560,41 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
|
|||||||
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
|
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
|
||||||
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
|
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restartRecoverySwitch(page: Page) {
|
||||||
|
return page.getByRole("switch", { name: "Resume agents after restart", exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartRecoveryEditButton(page: Page) {
|
||||||
|
return page.getByRole("button", { name: "Edit prompt", exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRestartRecoveryEnabled(page: Page, enabled: boolean) {
|
||||||
|
const toggle = restartRecoverySwitch(page);
|
||||||
|
await toggle.click();
|
||||||
|
await expectRestartRecoveryEnabled(page, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The prompt is only editable while restart recovery is on.
|
||||||
|
export async function expectRestartRecoveryEnabled(page: Page, enabled: boolean) {
|
||||||
|
await expect(restartRecoverySwitch(page)).toBeChecked({ checked: enabled });
|
||||||
|
await expect(restartRecoveryEditButton(page)).toBeEnabled({ enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editRestartRecoveryPrompt(page: Page, prompt: string) {
|
||||||
|
await restartRecoveryEditButton(page).click();
|
||||||
|
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
|
||||||
|
await sheet.getByRole("textbox", { name: "Resume prompt", exact: true }).fill(prompt);
|
||||||
|
await sheet.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
|
await expect(sheet).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
|
||||||
|
await restartRecoveryEditButton(page).click();
|
||||||
|
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
|
||||||
|
await expect(sheet.getByRole("textbox", { name: "Resume prompt", exact: true })).toHaveValue(
|
||||||
|
prompt,
|
||||||
|
);
|
||||||
|
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await expect(sheet).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,47 @@
|
|||||||
|
require "fileutils"
|
||||||
|
require "tmpdir"
|
||||||
|
|
||||||
default_platform(:ios)
|
default_platform(:ios)
|
||||||
|
|
||||||
APP_IDENTIFIER = "sh.paseo"
|
APP_IDENTIFIER = "sh.paseo"
|
||||||
|
|
||||||
|
# Store screenshots are rendered into assets/store-screenshots/output; see the
|
||||||
|
# README there. Google Play and F-Droid both read fastlane/metadata/android at
|
||||||
|
# the repo root, whose screenshot files link to those renders.
|
||||||
|
REPO_ROOT = File.expand_path("../../..", __dir__)
|
||||||
|
STORE_SCREENSHOTS = File.join(REPO_ROOT, "assets/store-screenshots/output")
|
||||||
|
ANDROID_METADATA = File.join(REPO_ROOT, "fastlane/metadata/android")
|
||||||
|
|
||||||
|
# deliver reads screenshots from one folder per language and picks the device
|
||||||
|
# slot from each image's size, so both Apple sets go into en-US together.
|
||||||
|
def stage_app_store_screenshots
|
||||||
|
root = Dir.mktmpdir("app-store-screenshots")
|
||||||
|
locale = File.join(root, "en-US")
|
||||||
|
FileUtils.mkdir_p(locale)
|
||||||
|
{ "apple" => "iphone", "apple-ipad" => "ipad" }.each do |set, device|
|
||||||
|
Dir.glob(File.join(STORE_SCREENSHOTS, set, "*.png")).sort.each do |file|
|
||||||
|
FileUtils.cp(file, File.join(locale, "#{device}-#{File.basename(file)}"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
root
|
||||||
|
end
|
||||||
|
|
||||||
|
# Google Play only has en-US screenshots, and some F-Droid locales in
|
||||||
|
# ANDROID_METADATA are not Play languages (Play rejects "ja"). supply uploads
|
||||||
|
# every locale folder in one edit, so give it only the en-US images.
|
||||||
|
def stage_google_play_screenshots
|
||||||
|
root = Dir.mktmpdir("google-play-screenshots")
|
||||||
|
images = File.join(ANDROID_METADATA, "en-US/images")
|
||||||
|
%w[phoneScreenshots tenInchScreenshots].each do |kind|
|
||||||
|
target = File.join(root, "en-US/images", kind)
|
||||||
|
FileUtils.mkdir_p(target)
|
||||||
|
Dir.glob(File.join(images, kind, "*.png")).sort.each do |file|
|
||||||
|
FileUtils.cp(File.realpath(file), File.join(target, File.basename(file)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
root
|
||||||
|
end
|
||||||
|
|
||||||
platform :ios do
|
platform :ios do
|
||||||
desc "Submit the latest TestFlight build to App Store Review"
|
desc "Submit the latest TestFlight build to App Store Review"
|
||||||
lane :submit_review do
|
lane :submit_review do
|
||||||
@@ -87,7 +127,8 @@ platform :ios do
|
|||||||
},
|
},
|
||||||
force: true,
|
force: true,
|
||||||
skip_binary_upload: true,
|
skip_binary_upload: true,
|
||||||
skip_screenshots: true,
|
screenshots_path: stage_app_store_screenshots,
|
||||||
|
overwrite_screenshots: true,
|
||||||
precheck_include_in_app_purchases: false,
|
precheck_include_in_app_purchases: false,
|
||||||
submission_information: {
|
submission_information: {
|
||||||
add_id_info_uses_idfa: false,
|
add_id_info_uses_idfa: false,
|
||||||
@@ -96,3 +137,25 @@ platform :ios do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
platform :android do
|
||||||
|
desc "Replace the Google Play store screenshots with the ones in the repo"
|
||||||
|
lane :upload_screenshots do
|
||||||
|
# supply attaches listing changes to a release on the production track.
|
||||||
|
# The release workflow passes the version code it just submitted; without
|
||||||
|
# it, supply uses the track's only release.
|
||||||
|
version_code = ENV["ANDROID_VERSION_CODE"].to_s.strip
|
||||||
|
supply(
|
||||||
|
package_name: APP_IDENTIFIER,
|
||||||
|
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
|
||||||
|
metadata_path: stage_google_play_screenshots,
|
||||||
|
version_code: version_code.empty? ? nil : version_code.to_i,
|
||||||
|
skip_upload_apk: true,
|
||||||
|
skip_upload_aab: true,
|
||||||
|
skip_upload_metadata: true,
|
||||||
|
skip_upload_changelogs: true,
|
||||||
|
skip_upload_images: true,
|
||||||
|
sync_image_upload: true,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ import { getDesktopHost } from "@/desktop/host";
|
|||||||
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
|
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||||
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
|
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
|
||||||
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
|
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
|
||||||
import { DaemonOutdatedCalloutSource } from "@/components/daemon-outdated-callout-source";
|
|
||||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||||
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
|
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
|
||||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||||
@@ -487,7 +486,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
<RosettaCalloutSource />
|
<RosettaCalloutSource />
|
||||||
<UpdateCalloutSource />
|
<UpdateCalloutSource />
|
||||||
<DaemonOutdatedCalloutSource />
|
|
||||||
<LegacyAgentSkillsMigration />
|
<LegacyAgentSkillsMigration />
|
||||||
<WorktreeSetupCalloutSource />
|
<WorktreeSetupCalloutSource />
|
||||||
<CommandCenterRootActions />
|
<CommandCenterRootActions />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
import equal from "fast-deep-equal";
|
import equal from "fast-deep-equal";
|
||||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||||
import {
|
import {
|
||||||
FlatList,
|
FlatList,
|
||||||
Modal,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
@@ -22,14 +23,7 @@ import {
|
|||||||
type ComponentType,
|
type ComponentType,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
Modal,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
type PressableStateCallbackType,
|
|
||||||
} from "react-native";
|
|
||||||
import {
|
import {
|
||||||
EditingTextInput as TextInput,
|
EditingTextInput as TextInput,
|
||||||
type EditingTextInputHandle,
|
type EditingTextInputHandle,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
Modal,
|
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
FlatList,
|
FlatList,
|
||||||
type ListRenderItem,
|
type ListRenderItem,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Modal, Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
|
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
type SidebarCalloutAction,
|
|
||||||
SidebarCalloutDescriptionText,
|
|
||||||
} from "@/components/sidebar-callout";
|
|
||||||
import {
|
|
||||||
type DaemonOutdatedUpdateState,
|
|
||||||
resolveDaemonOutdatedCallout,
|
|
||||||
} from "@/components/resolve-daemon-outdated-callout";
|
|
||||||
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
|
||||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
|
||||||
import { updateDaemonFromSettings } from "@/screens/settings/daemon-lifecycle";
|
|
||||||
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { orderHostsLocalFirst, resolveActiveHostServerId } from "@/types/host-connection";
|
|
||||||
import { resolveAppVersion } from "@/utils/app-version";
|
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
|
||||||
|
|
||||||
interface HostUpdateState {
|
|
||||||
serverId: string;
|
|
||||||
update: DaemonOutdatedUpdateState;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IDLE: DaemonOutdatedUpdateState = { status: "idle" };
|
|
||||||
|
|
||||||
function DaemonOutdatedDescription({ lines }: { lines: string[] }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{lines.map((line) => (
|
|
||||||
<SidebarCalloutDescriptionText key={line}>{line}</SidebarCalloutDescriptionText>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfirmAndUpdateDaemonInput {
|
|
||||||
serverId: string;
|
|
||||||
hostLabel: string;
|
|
||||||
client: DaemonClient;
|
|
||||||
t: TFunction;
|
|
||||||
onState: (next: DaemonOutdatedUpdateState) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmAndUpdateDaemon(input: ConfirmAndUpdateDaemonInput): Promise<void> {
|
|
||||||
const { serverId, hostLabel, client, t, onState } = input;
|
|
||||||
let confirmed: boolean;
|
|
||||||
try {
|
|
||||||
confirmed = await confirmDialog({
|
|
||||||
title: t("settings.host.daemon.update.confirmTitle", { name: hostLabel }),
|
|
||||||
message: t("settings.host.daemon.update.confirmMessage"),
|
|
||||||
confirmLabel: t("settings.host.daemon.update.confirm"),
|
|
||||||
cancelLabel: t("common.actions.cancel"),
|
|
||||||
destructive: false,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[DaemonOutdatedCallout] Failed to open update confirmation", error);
|
|
||||||
onState({ status: "failed", message: t("settings.host.daemon.update.dialogFailedMessage") });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
const phaseLabels = {
|
|
||||||
starting: t("settings.host.daemon.update.phaseStarting"),
|
|
||||||
downloading: t("settings.host.daemon.update.phaseDownloading"),
|
|
||||||
installing: t("settings.host.daemon.update.phaseInstalling"),
|
|
||||||
complete: t("settings.host.daemon.update.phaseComplete"),
|
|
||||||
};
|
|
||||||
onState({ status: "updating", phase: phaseLabels.starting });
|
|
||||||
const requestId = `sidebar_daemon_update_${serverId}`;
|
|
||||||
const unsubscribe = client.on("daemon.update.progress", (message) => {
|
|
||||||
if (message.payload.requestId !== requestId) return;
|
|
||||||
onState({ status: "updating", phase: phaseLabels[message.payload.phase] });
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await updateDaemonFromSettings(serverId, {
|
|
||||||
updateDaemon: () => client.updateDaemon(requestId),
|
|
||||||
getStatus: async () => ({
|
|
||||||
...(await client.getDaemonStatus()),
|
|
||||||
serverId: client.getLastServerInfoMessage()?.serverId ?? "",
|
|
||||||
version: client.getLastServerInfoMessage()?.version ?? null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
onState({ status: "idle" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[DaemonOutdatedCallout] Failed to update daemon ${hostLabel}`, error);
|
|
||||||
onState({
|
|
||||||
status: "failed",
|
|
||||||
message: t("settings.host.daemon.update.requestFailedMessage", {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
unsubscribe();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sidebar notice when the active host's daemon is older than this app. */
|
|
||||||
export function DaemonOutdatedCalloutSource() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const callouts = useSidebarCallouts();
|
|
||||||
const hosts = useHosts();
|
|
||||||
const localServerId = useLocalDaemonServerId();
|
|
||||||
const lastWorkspaceSelection = useLastWorkspaceSelection();
|
|
||||||
const orderedHosts = useMemo(
|
|
||||||
() => orderHostsLocalFirst(hosts, localServerId),
|
|
||||||
[hosts, localServerId],
|
|
||||||
);
|
|
||||||
// Same host resolution as settings: the last workspace's host, else the local daemon, else the first.
|
|
||||||
const serverId = resolveActiveHostServerId({
|
|
||||||
selectedServerId: lastWorkspaceSelection?.serverId ?? null,
|
|
||||||
localServerId,
|
|
||||||
hosts,
|
|
||||||
orderedHosts,
|
|
||||||
});
|
|
||||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? "";
|
|
||||||
const daemonClient = useHostRuntimeClient(serverId ?? "");
|
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
|
||||||
const serverInfo = useSessionStore((state) =>
|
|
||||||
serverId ? (state.sessions[serverId]?.serverInfo ?? null) : null,
|
|
||||||
);
|
|
||||||
const [hostUpdate, setHostUpdate] = useState<HostUpdateState | null>(null);
|
|
||||||
const update = hostUpdate && hostUpdate.serverId === serverId ? hostUpdate.update : IDLE;
|
|
||||||
|
|
||||||
const runUpdate = useStableEvent(() => {
|
|
||||||
if (!serverId || !daemonClient) return;
|
|
||||||
void confirmAndUpdateDaemon({
|
|
||||||
serverId,
|
|
||||||
hostLabel,
|
|
||||||
client: daemonClient,
|
|
||||||
t,
|
|
||||||
onState: (next) => setHostUpdate({ serverId, update: next }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!serverId || !serverInfo) return;
|
|
||||||
const descriptor = resolveDaemonOutdatedCallout({
|
|
||||||
serverId,
|
|
||||||
hostLabel,
|
|
||||||
appVersion: resolveAppVersion(),
|
|
||||||
daemonVersion: serverInfo.version,
|
|
||||||
supportsSelfUpdate: serverInfo.features?.daemonSelfUpdate === true,
|
|
||||||
desktopManaged: serverInfo.desktopManaged === true,
|
|
||||||
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
|
|
||||||
terminalsSurviveRestart: serverInfo.features?.terminalsSurviveRestart === true,
|
|
||||||
isConnected,
|
|
||||||
update,
|
|
||||||
});
|
|
||||||
if (!descriptor) return;
|
|
||||||
|
|
||||||
const actions: SidebarCalloutAction[] = descriptor.updateAction
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: descriptor.updateAction.label,
|
|
||||||
onPress: runUpdate,
|
|
||||||
variant: "primary",
|
|
||||||
disabled: descriptor.updateAction.disabled,
|
|
||||||
testID: "daemon-outdated-callout-update",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
return callouts.show({
|
|
||||||
id: descriptor.id,
|
|
||||||
dismissalKey: descriptor.dismissalKey,
|
|
||||||
priority: descriptor.priority,
|
|
||||||
title: descriptor.title,
|
|
||||||
description: <DaemonOutdatedDescription lines={descriptor.description} />,
|
|
||||||
variant: descriptor.variant,
|
|
||||||
actions,
|
|
||||||
testID: descriptor.testID,
|
|
||||||
native: true,
|
|
||||||
});
|
|
||||||
// t: the resolver reads the active language through i18n, so a language change rebuilds the copy.
|
|
||||||
}, [callouts, hostLabel, isConnected, runUpdate, serverId, serverInfo, t, update]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -40,7 +40,6 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
|
|||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isNative } from "@/constants/platform";
|
|
||||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||||
import { useImportSession } from "@/hooks/use-import-session";
|
import { useImportSession } from "@/hooks/use-import-session";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
@@ -606,8 +605,6 @@ function MobileSidebar({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SidebarCalloutSlot nativeOnly />
|
|
||||||
|
|
||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
handleOpenProject={handleOpenProject}
|
handleOpenProject={handleOpenProject}
|
||||||
handleSettings={handleSettings}
|
handleSettings={handleSettings}
|
||||||
@@ -787,7 +784,7 @@ function DesktopSidebar({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SidebarCalloutSlot nativeOnly={isNative} />
|
<SidebarCalloutSlot />
|
||||||
|
|
||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
handleOpenProject={handleOpenProject}
|
handleOpenProject={handleOpenProject}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { Modal, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Modal,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet as RNStyleSheet,
|
StyleSheet as RNStyleSheet,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function ResizeHandle({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const finePointer = useHasFinePointer();
|
const finePointer = useHasFinePointer();
|
||||||
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
||||||
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
|
const touchStateRef = useRef<PointerState | null>(null);
|
||||||
const cursorBeforeDragRef = useRef<string | null>(null);
|
const cursorBeforeDragRef = useRef<string | null>(null);
|
||||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [active, setActive] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
@@ -151,23 +151,30 @@ export function ResizeHandle({
|
|||||||
const touchGesture = useMemo(() => {
|
const touchGesture = useMemo(() => {
|
||||||
const gesture = Gesture.Pan()
|
const gesture = Gesture.Pan()
|
||||||
.runOnJS(true)
|
.runOnJS(true)
|
||||||
.onBegin(() => setDragging(true))
|
.onBegin((event) => {
|
||||||
.onStart(() => {
|
setDragging(true);
|
||||||
touchDragRef.current = startResizeHandleDrag({
|
touchStateRef.current = {
|
||||||
sizes,
|
containerSize,
|
||||||
index,
|
pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY,
|
||||||
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
|
drag: startResizeHandleDrag({
|
||||||
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
|
sizes,
|
||||||
});
|
index,
|
||||||
|
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
|
||||||
|
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
|
||||||
|
}),
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
if (containerSize <= 0) return;
|
const state = touchStateRef.current;
|
||||||
const translation = direction === "horizontal" ? event.translationX : event.translationY;
|
if (!state || state.containerSize <= 0) return;
|
||||||
touchDragRef.current?.move(translation / containerSize);
|
// Gesture Handler resets web translation when the pan activates. Measure from
|
||||||
|
// the initial press so the activation distance is included on every platform.
|
||||||
|
const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY;
|
||||||
|
state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize);
|
||||||
})
|
})
|
||||||
.onEnd(() => touchDragRef.current?.finish())
|
.onEnd(() => touchStateRef.current?.drag.finish())
|
||||||
.onFinalize(() => {
|
.onFinalize(() => {
|
||||||
touchDragRef.current = null;
|
touchStateRef.current = null;
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { i18n } from "@/i18n/i18next";
|
|
||||||
import {
|
|
||||||
resolveDaemonOutdatedCallout,
|
|
||||||
type ResolveDaemonOutdatedCalloutInput,
|
|
||||||
} from "./resolve-daemon-outdated-callout";
|
|
||||||
|
|
||||||
function input(
|
|
||||||
overrides: Partial<ResolveDaemonOutdatedCalloutInput> = {},
|
|
||||||
): ResolveDaemonOutdatedCalloutInput {
|
|
||||||
return {
|
|
||||||
serverId: "srv_alpha",
|
|
||||||
hostLabel: "Workstation",
|
|
||||||
appVersion: "0.12.0",
|
|
||||||
daemonVersion: "0.11.4",
|
|
||||||
supportsSelfUpdate: true,
|
|
||||||
desktopManaged: false,
|
|
||||||
terminalsSurviveRestart: true,
|
|
||||||
isConnected: true,
|
|
||||||
update: { status: "idle" },
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("resolveDaemonOutdatedCallout", () => {
|
|
||||||
it("shows an update notice when the daemon is older than the app", () => {
|
|
||||||
expect(resolveDaemonOutdatedCallout(input())).toEqual({
|
|
||||||
id: "daemon-outdated",
|
|
||||||
dismissalKey: "daemon-outdated:srv_alpha:0.11.4",
|
|
||||||
priority: 250,
|
|
||||||
title: "Daemon out of date",
|
|
||||||
description: [
|
|
||||||
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
|
|
||||||
"Updating restarts the daemon. Terminals stay open.",
|
|
||||||
],
|
|
||||||
variant: "default",
|
|
||||||
updateAction: { label: "Update", disabled: false },
|
|
||||||
testID: "daemon-outdated-callout",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides when the daemon matches or is newer than the app", () => {
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.12.0" }))).toBeNull();
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "v0.12.0" }))).toBeNull();
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.12.1" }))).toBeNull();
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "1.0.0" }))).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("compares numeric components numerically, not as strings", () => {
|
|
||||||
expect(
|
|
||||||
resolveDaemonOutdatedCallout(input({ appVersion: "0.10.0", daemonVersion: "0.9.9" })),
|
|
||||||
).not.toBeNull();
|
|
||||||
expect(
|
|
||||||
resolveDaemonOutdatedCallout(input({ appVersion: "0.9.9", daemonVersion: "0.10.0" })),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("orders prereleases before their release", () => {
|
|
||||||
expect(
|
|
||||||
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0", daemonVersion: "0.12.0-beta.1" })),
|
|
||||||
).not.toBeNull();
|
|
||||||
expect(
|
|
||||||
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0-beta.1", daemonVersion: "0.12.0" })),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
resolveDaemonOutdatedCallout(
|
|
||||||
input({ appVersion: "0.12.0-beta.10", daemonVersion: "0.12.0-beta.2" }),
|
|
||||||
),
|
|
||||||
).not.toBeNull();
|
|
||||||
expect(
|
|
||||||
resolveDaemonOutdatedCallout(
|
|
||||||
input({ appVersion: "0.12.0-beta.2", daemonVersion: "0.12.0-beta.2" }),
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0-rc.1", daemonVersion: "0.11.9" })),
|
|
||||||
).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides when either version is missing or unparseable", () => {
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: null }))).toBeNull();
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ appVersion: null }))).toBeNull();
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "dev" }))).toBeNull();
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.11" }))).toBeNull();
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ appVersion: "unknown" }))).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers no button for a desktop-managed daemon and points to Paseo on that computer", () => {
|
|
||||||
const descriptor = resolveDaemonOutdatedCallout(input({ desktopManaged: true }));
|
|
||||||
|
|
||||||
expect(descriptor?.updateAction).toBeNull();
|
|
||||||
expect(descriptor?.description).toEqual([
|
|
||||||
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
|
|
||||||
"Paseo Desktop manages this daemon. Update Paseo on that computer.",
|
|
||||||
"Updating Paseo Desktop keeps terminals open on macOS and Linux, and closes them on Windows.",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never promises terminals survive a desktop update, which closes them on Windows", () => {
|
|
||||||
const descriptor = resolveDaemonOutdatedCallout(
|
|
||||||
input({ desktopManaged: true, terminalsSurviveRestart: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(descriptor?.description).not.toContain(
|
|
||||||
"Updating restarts the daemon. Terminals stay open.",
|
|
||||||
);
|
|
||||||
expect(descriptor?.description.at(-1)).toContain("closes them on Windows");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("says a desktop update closes terminals when the daemon does not keep them", () => {
|
|
||||||
const descriptor = resolveDaemonOutdatedCallout(
|
|
||||||
input({ desktopManaged: true, terminalsSurviveRestart: false }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(descriptor?.description.at(-1)).toBe(
|
|
||||||
"Updating restarts the daemon and closes open terminals.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers no button when the daemon does not support self-update", () => {
|
|
||||||
const descriptor = resolveDaemonOutdatedCallout(input({ supportsSelfUpdate: false }));
|
|
||||||
|
|
||||||
expect(descriptor?.updateAction).toBeNull();
|
|
||||||
expect(descriptor?.description).toContain(
|
|
||||||
"This daemon can't update itself. Update Paseo on that computer.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("says the update closes terminals when the daemon does not keep them", () => {
|
|
||||||
const descriptor = resolveDaemonOutdatedCallout(input({ terminalsSurviveRestart: false }));
|
|
||||||
|
|
||||||
expect(descriptor?.description).toEqual([
|
|
||||||
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
|
|
||||||
"Updating restarts the daemon and closes open terminals.",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables the button while the host is disconnected", () => {
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ isConnected: false }))?.updateAction).toEqual({
|
|
||||||
label: "Update",
|
|
||||||
disabled: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the update phase on a disabled button while updating", () => {
|
|
||||||
const descriptor = resolveDaemonOutdatedCallout(
|
|
||||||
input({ update: { status: "updating", phase: "Downloading packages..." } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(descriptor?.variant).toBe("default");
|
|
||||||
expect(descriptor?.updateAction).toEqual({ label: "Downloading packages...", disabled: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("surfaces a failed update as an error with the button available again", () => {
|
|
||||||
const descriptor = resolveDaemonOutdatedCallout(
|
|
||||||
input({ update: { status: "failed", message: "Failed to update the daemon: boom" } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(descriptor?.title).toBe("Daemon update failed");
|
|
||||||
expect(descriptor?.variant).toBe("error");
|
|
||||||
expect(descriptor?.description).toEqual(["Failed to update the daemon: boom"]);
|
|
||||||
expect(descriptor?.updateAction).toEqual({ label: "Update", disabled: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keys dismissal by host and daemon version", () => {
|
|
||||||
const key = resolveDaemonOutdatedCallout(input())?.dismissalKey;
|
|
||||||
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "v0.11.4" }))?.dismissalKey).toBe(
|
|
||||||
key,
|
|
||||||
);
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ appVersion: "0.13.0" }))?.dismissalKey).toBe(key);
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.11.5" }))?.dismissalKey).toBe(
|
|
||||||
"daemon-outdated:srv_alpha:0.11.5",
|
|
||||||
);
|
|
||||||
expect(resolveDaemonOutdatedCallout(input({ serverId: "srv_beta" }))?.dismissalKey).toBe(
|
|
||||||
"daemon-outdated:srv_beta:0.11.4",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the active app language", async () => {
|
|
||||||
await i18n.changeLanguage("es");
|
|
||||||
try {
|
|
||||||
const descriptor = resolveDaemonOutdatedCallout(input({ terminalsSurviveRestart: false }));
|
|
||||||
|
|
||||||
expect(descriptor?.title).toBe("Daemon desactualizado");
|
|
||||||
expect(descriptor?.description).toEqual([
|
|
||||||
"Workstation ejecuta el daemon v0.11.4. Esta app es v0.12.0.",
|
|
||||||
"Actualizar reinicia el daemon y cierra las terminales abiertas.",
|
|
||||||
]);
|
|
||||||
expect(descriptor?.updateAction?.label).toBe("Actualizar");
|
|
||||||
} finally {
|
|
||||||
await i18n.changeLanguage("en");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { i18n } from "@/i18n/i18next";
|
|
||||||
|
|
||||||
export type DaemonOutdatedUpdateState =
|
|
||||||
| { status: "idle" }
|
|
||||||
| { status: "updating"; phase: string }
|
|
||||||
| { status: "failed"; message: string };
|
|
||||||
|
|
||||||
export interface ResolveDaemonOutdatedCalloutInput {
|
|
||||||
serverId: string;
|
|
||||||
hostLabel: string;
|
|
||||||
appVersion: string | null;
|
|
||||||
daemonVersion: string | null;
|
|
||||||
supportsSelfUpdate: boolean;
|
|
||||||
desktopManaged: boolean;
|
|
||||||
terminalsSurviveRestart: boolean;
|
|
||||||
isConnected: boolean;
|
|
||||||
update: DaemonOutdatedUpdateState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DaemonOutdatedUpdateAction {
|
|
||||||
label: string;
|
|
||||||
disabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DaemonOutdatedCalloutDescriptor {
|
|
||||||
id: "daemon-outdated";
|
|
||||||
dismissalKey: string;
|
|
||||||
priority: number;
|
|
||||||
title: string;
|
|
||||||
description: string[];
|
|
||||||
variant: "default" | "error";
|
|
||||||
updateAction: DaemonOutdatedUpdateAction | null;
|
|
||||||
testID: "daemon-outdated-callout";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedVersion {
|
|
||||||
core: [number, number, number];
|
|
||||||
prerelease: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const VERSION_PATTERN =
|
|
||||||
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/i;
|
|
||||||
const NUMERIC_IDENTIFIER = /^\d+$/;
|
|
||||||
|
|
||||||
function parseVersion(value: string | null): ParsedVersion | null {
|
|
||||||
const match = value?.trim().match(VERSION_PATTERN);
|
|
||||||
if (!match) return null;
|
|
||||||
return {
|
|
||||||
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
||||||
prerelease: match[4] ? match[4].split(".") : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareIdentifiers(left: string, right: string): number {
|
|
||||||
const isLeftNumeric = NUMERIC_IDENTIFIER.test(left);
|
|
||||||
const isRightNumeric = NUMERIC_IDENTIFIER.test(right);
|
|
||||||
if (isLeftNumeric && isRightNumeric) return Number(left) - Number(right);
|
|
||||||
if (isLeftNumeric) return -1;
|
|
||||||
if (isRightNumeric) return 1;
|
|
||||||
if (left < right) return -1;
|
|
||||||
if (left > right) return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Semver precedence: a prerelease sorts before its release (0.12.0-beta.1 < 0.12.0).
|
|
||||||
function compareVersions(left: ParsedVersion, right: ParsedVersion): number {
|
|
||||||
for (let index = 0; index < 3; index += 1) {
|
|
||||||
const difference = left.core[index] - right.core[index];
|
|
||||||
if (difference !== 0) return difference;
|
|
||||||
}
|
|
||||||
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
|
|
||||||
return right.prerelease.length - left.prerelease.length;
|
|
||||||
}
|
|
||||||
const sharedLength = Math.min(left.prerelease.length, right.prerelease.length);
|
|
||||||
for (let index = 0; index < sharedLength; index += 1) {
|
|
||||||
const difference = compareIdentifiers(left.prerelease[index], right.prerelease[index]);
|
|
||||||
if (difference !== 0) return difference;
|
|
||||||
}
|
|
||||||
return left.prerelease.length - right.prerelease.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveUpdateAction(input: ResolveDaemonOutdatedCalloutInput): DaemonOutdatedUpdateAction {
|
|
||||||
if (input.update.status === "updating") {
|
|
||||||
return { label: input.update.phase, disabled: true };
|
|
||||||
}
|
|
||||||
return { label: i18n.t("daemonOutdated.update"), disabled: !input.isConnected };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatVersion(version: ParsedVersion): string {
|
|
||||||
const core = version.core.join(".");
|
|
||||||
return version.prerelease.length > 0 ? `${core}-${version.prerelease.join(".")}` : core;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A daemon that keeps terminals across restarts does not keep them across a Paseo Desktop update
|
|
||||||
// on Windows, which closes them. server_info does not name the host's platform, so the line says
|
|
||||||
// both.
|
|
||||||
function resolveTerminalsLine(input: ResolveDaemonOutdatedCalloutInput): string {
|
|
||||||
if (!input.terminalsSurviveRestart) return i18n.t("daemonOutdated.terminalsClose");
|
|
||||||
if (input.desktopManaged) return i18n.t("daemonOutdated.desktopUpdateTerminals");
|
|
||||||
return i18n.t("daemonOutdated.terminalsStayOpen");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDescription(
|
|
||||||
input: ResolveDaemonOutdatedCalloutInput,
|
|
||||||
versions: { daemon: ParsedVersion; app: ParsedVersion },
|
|
||||||
): string[] {
|
|
||||||
if (input.update.status === "failed") return [input.update.message];
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
i18n.t("daemonOutdated.versions", {
|
|
||||||
host: input.hostLabel,
|
|
||||||
daemonVersion: `v${formatVersion(versions.daemon)}`,
|
|
||||||
appVersion: `v${formatVersion(versions.app)}`,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
if (input.desktopManaged) {
|
|
||||||
lines.push(i18n.t("daemonOutdated.desktopManaged"));
|
|
||||||
} else if (!input.supportsSelfUpdate) {
|
|
||||||
lines.push(i18n.t("daemonOutdated.selfUpdateUnsupported"));
|
|
||||||
}
|
|
||||||
lines.push(resolveTerminalsLine(input));
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveDaemonOutdatedCallout(
|
|
||||||
input: ResolveDaemonOutdatedCalloutInput,
|
|
||||||
): DaemonOutdatedCalloutDescriptor | null {
|
|
||||||
const app = parseVersion(input.appVersion);
|
|
||||||
const daemon = parseVersion(input.daemonVersion);
|
|
||||||
if (!app || !daemon) return null;
|
|
||||||
if (compareVersions(daemon, app) >= 0) return null;
|
|
||||||
|
|
||||||
// The update RPC refuses desktop-managed daemons; those update with Paseo Desktop on that computer.
|
|
||||||
const canSelfUpdate = input.supportsSelfUpdate && !input.desktopManaged;
|
|
||||||
const isFailed = input.update.status === "failed";
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: "daemon-outdated",
|
|
||||||
dismissalKey: `daemon-outdated:${input.serverId}:${formatVersion(daemon)}`,
|
|
||||||
priority: 250,
|
|
||||||
title: isFailed ? i18n.t("daemonOutdated.updateFailedTitle") : i18n.t("daemonOutdated.title"),
|
|
||||||
description: resolveDescription(input, { daemon, app }),
|
|
||||||
variant: isFailed ? "error" : "default",
|
|
||||||
updateAction: canSelfUpdate ? resolveUpdateAction(input) : null,
|
|
||||||
testID: "daemon-outdated-callout",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./sidebar-callout-slot.native";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function SidebarCalloutSlot() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ describe("SidebarCalloutSlot", () => {
|
|||||||
root?.render(
|
root?.render(
|
||||||
<SidebarCalloutProvider>
|
<SidebarCalloutProvider>
|
||||||
<RegisterCallout />
|
<RegisterCallout />
|
||||||
<SidebarCalloutSlot nativeOnly={false} />
|
<SidebarCalloutSlot />
|
||||||
</SidebarCalloutProvider>,
|
</SidebarCalloutProvider>,
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -105,7 +105,7 @@ describe("SidebarCalloutSlot", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(
|
root?.render(
|
||||||
<SidebarCalloutProvider>
|
<SidebarCalloutProvider>
|
||||||
<SidebarCalloutSlot nativeOnly={false} />
|
<SidebarCalloutSlot />
|
||||||
</SidebarCalloutProvider>,
|
</SidebarCalloutProvider>,
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|||||||
+2
-6
@@ -2,14 +2,10 @@ import { View } from "react-native";
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { SidebarCalloutViewport } from "@/contexts/sidebar-callout-context";
|
import { SidebarCalloutViewport } from "@/contexts/sidebar-callout-context";
|
||||||
|
|
||||||
/**
|
export function SidebarCalloutSlot() {
|
||||||
* `nativeOnly` limits the slot to callouts marked `native`. Native apps and every compact
|
|
||||||
* sidebar pass it: the other callouts are about the desktop app.
|
|
||||||
*/
|
|
||||||
export function SidebarCalloutSlot({ nativeOnly }: { nativeOnly: boolean }) {
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.slot} collapsable={false}>
|
<View style={styles.slot} collapsable={false}>
|
||||||
<SidebarCalloutViewport nativeOnly={nativeOnly} />
|
<SidebarCalloutViewport />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
|||||||
import {
|
import {
|
||||||
collectAllPanes,
|
collectAllPanes,
|
||||||
createDefaultLayout,
|
createDefaultLayout,
|
||||||
|
selectExplorerSidebarPaneId,
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
type SplitNode,
|
type SplitNode,
|
||||||
type SplitPane,
|
type SplitPane,
|
||||||
@@ -183,8 +184,8 @@ export function SplitContainer({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const maximizedPaneId =
|
const maximizedPaneId =
|
||||||
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
|
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
|
||||||
const explorerSidebarPaneId = useWorkspaceLayoutStore(
|
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
|
||||||
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
|
selectExplorerSidebarPaneId(state, workspaceKey),
|
||||||
);
|
);
|
||||||
|
|
||||||
const mainRoot = useMemo(
|
const mainRoot = useMemo(
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function resolveShouldFill(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (!fillAvailableHeight) return false;
|
if (!fillAvailableHeight) return false;
|
||||||
const t = detail?.type;
|
const t = detail?.type;
|
||||||
return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent";
|
return t === "shell" || t === "edit" || t === "write" || t === "read";
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDetailStyles(
|
function useDetailStyles(
|
||||||
@@ -186,184 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSubAgentFallbackHeader(
|
|
||||||
subAgentType: string | null | undefined,
|
|
||||||
description: string | null | undefined,
|
|
||||||
fallbackText: string,
|
|
||||||
): string {
|
|
||||||
if (subAgentType && description) {
|
|
||||||
return `${subAgentType}: ${description}`;
|
|
||||||
}
|
|
||||||
return subAgentType ?? description ?? fallbackText;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SubAgentDetailProps {
|
|
||||||
log: string;
|
|
||||||
childSessionId: string | null | undefined;
|
|
||||||
subAgentType: string | null | undefined;
|
|
||||||
description: string | null | undefined;
|
|
||||||
ds: DetailStyles;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SubAgentActivityRow {
|
|
||||||
index: number;
|
|
||||||
toolName: string;
|
|
||||||
summary?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedSubAgentLog {
|
|
||||||
actions: SubAgentActivityRow[];
|
|
||||||
remainingLog: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null {
|
|
||||||
const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/);
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const toolName = match[1]?.trim();
|
|
||||||
if (!toolName) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const summary = match[2]?.trim();
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
toolName,
|
|
||||||
...(summary ? { summary } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSubAgentLog(log: string): ParsedSubAgentLog {
|
|
||||||
const actions: SubAgentActivityRow[] = [];
|
|
||||||
const remainingLines: string[] = [];
|
|
||||||
for (const line of log.replace(/^\n+/, "").split("\n")) {
|
|
||||||
const normalizedLine = line.trim();
|
|
||||||
if (!normalizedLine) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1);
|
|
||||||
if (parsedAction) {
|
|
||||||
actions.push(parsedAction);
|
|
||||||
} else {
|
|
||||||
remainingLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
actions,
|
|
||||||
remainingLog: remainingLines.join("\n").replace(/^\n+/, ""),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) {
|
|
||||||
return (
|
|
||||||
<View style={styles.subAgentActionRow}>
|
|
||||||
<Text selectable style={styles.subAgentActionTool}>
|
|
||||||
{formatSubAgentToolName(action.toolName)}
|
|
||||||
</Text>
|
|
||||||
{action.summary ? (
|
|
||||||
<Text selectable style={styles.subAgentActionSummary}>
|
|
||||||
{action.summary}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSubAgentToolName(toolName: string): string {
|
|
||||||
const trimmed = toolName.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return toolName;
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
.replace(/[._-]+/g, " ")
|
|
||||||
.split(" ")
|
|
||||||
.filter((segment) => segment.length > 0)
|
|
||||||
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function SubAgentLogText({
|
|
||||||
activityLog,
|
|
||||||
fallbackHeader,
|
|
||||||
hasActions,
|
|
||||||
}: {
|
|
||||||
activityLog: string;
|
|
||||||
fallbackHeader: string;
|
|
||||||
hasActions: boolean;
|
|
||||||
}) {
|
|
||||||
if (activityLog.length > 0) {
|
|
||||||
return (
|
|
||||||
<Text selectable style={styles.scrollText}>
|
|
||||||
{activityLog}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasActions) {
|
|
||||||
return (
|
|
||||||
<Text selectable style={styles.scrollText}>
|
|
||||||
{fallbackHeader}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SubAgentDetailSection({
|
|
||||||
log,
|
|
||||||
childSessionId,
|
|
||||||
subAgentType,
|
|
||||||
description,
|
|
||||||
ds,
|
|
||||||
}: SubAgentDetailProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]);
|
|
||||||
const fallbackHeader = resolveSubAgentFallbackHeader(
|
|
||||||
subAgentType,
|
|
||||||
description,
|
|
||||||
t("toolCallDetails.subAgentActivity"),
|
|
||||||
);
|
|
||||||
const hasActions = actions.length > 0;
|
|
||||||
return (
|
|
||||||
<View style={ds.sectionFillStyle}>
|
|
||||||
<View style={ds.codeBlockFillStyle}>
|
|
||||||
<ScrollView
|
|
||||||
style={ds.codeVerticalScrollStyle}
|
|
||||||
contentContainerStyle={styles.codeVerticalContent}
|
|
||||||
nestedScrollEnabled
|
|
||||||
showsVerticalScrollIndicator
|
|
||||||
>
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
nestedScrollEnabled
|
|
||||||
showsHorizontalScrollIndicator
|
|
||||||
contentContainerStyle={styles.codeHorizontalContent}
|
|
||||||
>
|
|
||||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
|
||||||
{childSessionId ? (
|
|
||||||
<Text selectable style={styles.subAgentSessionText}>
|
|
||||||
session {childSessionId}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
{hasActions ? (
|
|
||||||
<View style={styles.subAgentActions}>
|
|
||||||
{actions.map((action) => (
|
|
||||||
<SubAgentActionRow key={action.index} action={action} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<SubAgentLogText
|
|
||||||
activityLog={remainingLog}
|
|
||||||
fallbackHeader={fallbackHeader}
|
|
||||||
hasActions={hasActions}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditDetailProps {
|
interface EditDetailProps {
|
||||||
diffLines: DiffLine[] | undefined;
|
diffLines: DiffLine[] | undefined;
|
||||||
ds: DetailStyles;
|
ds: DetailStyles;
|
||||||
@@ -636,19 +458,7 @@ function buildDetailSections(
|
|||||||
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
|
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (detail.type === "worktree_setup") return [];
|
if (detail.type === "worktree_setup" || detail.type === "sub_agent") return [];
|
||||||
if (detail.type === "sub_agent") {
|
|
||||||
return [
|
|
||||||
<SubAgentDetailSection
|
|
||||||
key="sub-agent"
|
|
||||||
log={detail.log}
|
|
||||||
childSessionId={detail.childSessionId}
|
|
||||||
subAgentType={detail.subAgentType}
|
|
||||||
description={detail.description}
|
|
||||||
ds={ds}
|
|
||||||
/>,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (detail.type === "edit") {
|
if (detail.type === "edit") {
|
||||||
return [<EditDetailSection key="edit" diffLines={diffLines} ds={ds} />];
|
return [<EditDetailSection key="edit" diffLines={diffLines} ds={ds} />];
|
||||||
}
|
}
|
||||||
@@ -900,34 +710,6 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
shellPrompt: {
|
shellPrompt: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
subAgentSessionText: {
|
|
||||||
fontFamily: theme.fontFamily.mono,
|
|
||||||
fontSize: theme.fontSize.code,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
lineHeight: 18,
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
subAgentActions: {
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
subAgentActionRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
subAgentActionTool: {
|
|
||||||
fontFamily: theme.fontFamily.mono,
|
|
||||||
fontSize: theme.fontSize.code,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
lineHeight: 18,
|
|
||||||
},
|
|
||||||
subAgentActionSummary: {
|
|
||||||
fontFamily: theme.fontFamily.mono,
|
|
||||||
fontSize: theme.fontSize.code,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
lineHeight: 18,
|
|
||||||
},
|
|
||||||
jsonScroll: {
|
jsonScroll: {
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -12,7 +13,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
Modal,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Platform,
|
Platform,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { act, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { page } from "vitest/browser";
|
||||||
|
import { MenuRoot, MenuSurface, MenuTrigger, type Size } from "./index";
|
||||||
|
|
||||||
|
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||||
|
beforeEach(() => vi.stubGlobal("React", React));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real browser, because the regression lives in Reanimated's web entering path: it snapshots
|
||||||
|
* the surface's rect when a custom keyframe ends and writes that rect back as inline
|
||||||
|
* top/left/width/height when it cleans the keyframe up, 750ms after the animation starts.
|
||||||
|
* Anything that moved or resized the surface in between is undone by that write.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Mounted {
|
||||||
|
root: Root;
|
||||||
|
container: HTMLDivElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mounted: Mounted[] = [];
|
||||||
|
|
||||||
|
function mount(node: ReactNode): void {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
act(() => root.render(node));
|
||||||
|
mounted.push({ root, container });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const entry of mounted.splice(0)) {
|
||||||
|
act(() => entry.root.unmount());
|
||||||
|
entry.container.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const OFFSET = 12;
|
||||||
|
const PILL_POSITION = { position: "absolute", top: 700, left: 40 } as const;
|
||||||
|
const SHORT: Size = { width: 200, height: 40 };
|
||||||
|
const TALL: Size = { width: 200, height: 240 };
|
||||||
|
const WIDE: Size = { width: 480, height: 40 };
|
||||||
|
/** Inside the window between the entering animation ending and Reanimated's cleanup. */
|
||||||
|
const GROWS_BEFORE_CLEANUP_MS = 400;
|
||||||
|
/** Past Reanimated's cleanup, where the surface used to keep a fixed height. */
|
||||||
|
const GROWS_AFTER_CLEANUP_MS = 1_000;
|
||||||
|
/** Long enough after the content grew for anything that would write to the surface to land. */
|
||||||
|
const SETTLE_MS = 1_200;
|
||||||
|
|
||||||
|
/** Plain timers, not `act`: an async `act` holds every update until it resolves. */
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function element(testID: string): HTMLElement {
|
||||||
|
const found = document.querySelector(`[data-testid="${testID}"]`);
|
||||||
|
if (!(found instanceof HTMLElement)) throw new Error(`${testID} did not render`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stands in for a popover whose data lands after it opened, like a plugin's first RPC. */
|
||||||
|
function GrowsAfterOpen({ afterMs, from, to }: { afterMs: number; from: Size; to: Size }) {
|
||||||
|
const [grown, setGrown] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setGrown(true), afterMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [afterMs]);
|
||||||
|
return <View testID="body" style={grown ? to : from} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountMenuAbovePill(body: ReactNode): void {
|
||||||
|
mount(
|
||||||
|
<View style={PILL_POSITION}>
|
||||||
|
<MenuRoot>
|
||||||
|
<MenuTrigger testID="pill" accessibilityLabel="Open panel">
|
||||||
|
<Text>Open panel</Text>
|
||||||
|
</MenuTrigger>
|
||||||
|
<MenuSurface
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
offset={OFFSET}
|
||||||
|
minWidth={280}
|
||||||
|
maxWidth={620}
|
||||||
|
testID="panel"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</MenuSurface>
|
||||||
|
</MenuRoot>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
act(() => element("pill").click());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("anchored menu surface on web", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await page.viewport(1280, 900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([GROWS_BEFORE_CLEANUP_MS, GROWS_AFTER_CLEANUP_MS])(
|
||||||
|
"stays on its anchor when its content grows %ims after opening",
|
||||||
|
async (afterMs) => {
|
||||||
|
mountMenuAbovePill(<GrowsAfterOpen afterMs={afterMs} from={SHORT} to={TALL} />);
|
||||||
|
|
||||||
|
await wait(afterMs + SETTLE_MS);
|
||||||
|
|
||||||
|
const pill = element("pill").getBoundingClientRect();
|
||||||
|
const panel = element("panel").getBoundingClientRect();
|
||||||
|
expect(panel.height).toBeGreaterThan(240);
|
||||||
|
expect(panel.bottom).toBeLessThanOrEqual(pill.top);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("widens when its content widens after the entering animation", async () => {
|
||||||
|
mountMenuAbovePill(<GrowsAfterOpen afterMs={GROWS_BEFORE_CLEANUP_MS} from={SHORT} to={WIDE} />);
|
||||||
|
|
||||||
|
await wait(GROWS_BEFORE_CLEANUP_MS + SETTLE_MS);
|
||||||
|
|
||||||
|
const body = element("body").getBoundingClientRect();
|
||||||
|
const panel = element("panel").getBoundingClientRect();
|
||||||
|
expect(panel.width).toBeGreaterThanOrEqual(body.width);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useId,
|
useId,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
@@ -11,7 +13,6 @@ import { createPortal } from "react-dom";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Modal,
|
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
View,
|
View,
|
||||||
@@ -47,56 +48,41 @@ const contentEntering = new Keyframe({
|
|||||||
100: { opacity: 1, transform: [{ scale: 1 }] },
|
100: { opacity: 1, transform: [{ scale: 1 }] },
|
||||||
}).duration(CONTENT_ENTERING_DURATION_MS);
|
}).duration(CONTENT_ENTERING_DURATION_MS);
|
||||||
|
|
||||||
|
/** `contentEntering` for the browser's own animation engine. Both default to linear easing. */
|
||||||
|
const WEB_CONTENT_ENTERING_KEYFRAMES: PropertyIndexedKeyframes = {
|
||||||
|
opacity: [0, 1],
|
||||||
|
transform: ["scale(0.97)", "scale(1)"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Web plays the same keyframes itself; see `useWebContentEntering`. */
|
||||||
|
const nativeContentEntering = isWeb ? undefined : contentEntering;
|
||||||
|
|
||||||
const contentExiting = new Keyframe({
|
const contentExiting = new Keyframe({
|
||||||
0: { opacity: 1, transform: [{ scale: 1 }] },
|
0: { opacity: 1, transform: [{ scale: 1 }] },
|
||||||
100: { opacity: 0, transform: [{ scale: 0.97 }] },
|
100: { opacity: 0, transform: [{ scale: 0.97 }] },
|
||||||
}).duration(100);
|
}).duration(100);
|
||||||
|
|
||||||
function releaseFixedMenuHeight(surfaceNativeID: string): void {
|
|
||||||
if (!isWeb) return;
|
|
||||||
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reanimated's web entering animation leaves an inline height snapshot on the measured surface.
|
* On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's
|
||||||
* Once the menu is open, height must return to content-sized so rows can grow in place — and so
|
* web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe
|
||||||
* a submenu page taller than the page it replaced is not clipped to the old height.
|
* up 750ms after the start, writes that rect back as inline top/left/width/height. A surface that
|
||||||
*
|
* moved or resized in between — content that grew once its data arrived — snapped back to the old
|
||||||
* `revision` is what makes that second case work: bump it whenever the rendered content changes
|
* rect and stayed there, clipped or hanging past its anchor. A Web Animation leaves no inline style.
|
||||||
* identity (a page push, for instance) and the snapshot is released again.
|
|
||||||
*/
|
*/
|
||||||
function useReleaseFixedMenuHeight({
|
function useWebContentEntering({
|
||||||
contentSize,
|
placed,
|
||||||
enabled,
|
|
||||||
surfaceNativeID,
|
surfaceNativeID,
|
||||||
revision,
|
|
||||||
}: {
|
}: {
|
||||||
contentSize: Size | null;
|
placed: boolean;
|
||||||
enabled: boolean;
|
|
||||||
surfaceNativeID: string;
|
surfaceNativeID: string;
|
||||||
revision?: string | number;
|
|
||||||
}): void {
|
}): void {
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!enabled) return undefined;
|
if (!isWeb || !placed || typeof document === "undefined") return undefined;
|
||||||
|
const animation = document
|
||||||
const release = () => {
|
.getElementById(surfaceNativeID)
|
||||||
releaseFixedMenuHeight(surfaceNativeID);
|
?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS);
|
||||||
if (typeof requestAnimationFrame === "function") {
|
return () => animation?.cancel();
|
||||||
requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID));
|
}, [placed, surfaceNativeID]);
|
||||||
}
|
|
||||||
};
|
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [
|
|
||||||
setTimeout(release, CONTENT_ENTERING_DURATION_MS),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (contentSize) {
|
|
||||||
timers.push(setTimeout(release, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
for (const timer of timers) clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [contentSize, enabled, surfaceNativeID, revision]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,8 +210,6 @@ export interface AnchoredSurfaceProps {
|
|||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
horizontalPadding?: number;
|
horizontalPadding?: number;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
/** Bump when the rendered content changes identity, so the height snapshot is released. */
|
|
||||||
revision?: string | number;
|
|
||||||
/** A submenu sits inside its parent's overlay and must not paint a second backdrop. */
|
/** A submenu sits inside its parent's overlay and must not paint a second backdrop. */
|
||||||
backdrop?: boolean;
|
backdrop?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -259,7 +243,6 @@ export function AnchoredSurface({
|
|||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
horizontalPadding = 16,
|
horizontalPadding = 16,
|
||||||
scrollable = false,
|
scrollable = false,
|
||||||
revision,
|
|
||||||
backdrop = true,
|
backdrop = true,
|
||||||
onPointerEnter,
|
onPointerEnter,
|
||||||
onPointerLeave,
|
onPointerLeave,
|
||||||
@@ -269,33 +252,25 @@ export function AnchoredSurface({
|
|||||||
}: AnchoredSurfaceProps): ReactElement | null {
|
}: AnchoredSurfaceProps): ReactElement | null {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const surfaceNativeID = useId();
|
const surfaceNativeID = useId();
|
||||||
const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } =
|
const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({
|
||||||
useAnchoredPosition({
|
open,
|
||||||
open,
|
anchorRect,
|
||||||
anchorRect,
|
anchorRef,
|
||||||
anchorRef,
|
side,
|
||||||
side,
|
align,
|
||||||
align,
|
offset,
|
||||||
offset,
|
scrollable,
|
||||||
scrollable,
|
maxHeight,
|
||||||
maxHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
useReleaseFixedMenuHeight({
|
|
||||||
contentSize,
|
|
||||||
enabled: open,
|
|
||||||
surfaceNativeID,
|
|
||||||
revision,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The surface is placed once both measurements land. Until then it sits off-screen so it can
|
// The surface is placed once both measurements land. Until then it sits off-screen so it can
|
||||||
// be measured, and it must not animate: Reanimated's web entering path snapshots the element's
|
// be measured, and it must not animate: an entering animation that ran there would be over
|
||||||
// rect when the animation ends and writes that rect back as inline top/left when it cleans the
|
// before anyone saw the menu. Remounting on placement starts the native animation from the
|
||||||
// keyframe up 750ms later. If the animation ends before placement, the snapshot is the
|
// final position; web starts its own animation on placement.
|
||||||
// off-screen rect and the menu jumps back off-screen. Remounting on placement starts the
|
|
||||||
// animation from the final position.
|
|
||||||
const placed = position !== null;
|
const placed = position !== null;
|
||||||
|
|
||||||
|
useWebContentEntering({ placed, surfaceNativeID });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWeb || !open || !placed || typeof document === "undefined") return undefined;
|
if (!isWeb || !open || !placed || typeof document === "undefined") return undefined;
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
@@ -383,7 +358,7 @@ export function AnchoredSurface({
|
|||||||
dataSet={surfaceDataSet}
|
dataSet={surfaceDataSet}
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
frameStyle={frameStyle}
|
frameStyle={frameStyle}
|
||||||
entering={placed ? contentEntering : undefined}
|
entering={placed ? nativeContentEntering : undefined}
|
||||||
exiting={
|
exiting={
|
||||||
!placed || isWeb || !onExited
|
!placed || isWeb || !onExited
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -344,7 +344,6 @@ function MenuFlyout({
|
|||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
scrollable={scrollable}
|
scrollable={scrollable}
|
||||||
backdrop={false}
|
backdrop={false}
|
||||||
revision={page.id}
|
|
||||||
onPointerEnter={cancelHoverClose}
|
onPointerEnter={cancelHoverClose}
|
||||||
onPointerLeave={handleHoverOut}
|
onPointerLeave={handleHoverOut}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Modal as NativeModal, type ModalProps } from "react-native";
|
||||||
|
|
||||||
|
// React Native defaults iPhone modals to portrait even when the app supports rotation.
|
||||||
|
const orientations: NonNullable<ModalProps["supportedOrientations"]> = [
|
||||||
|
"portrait",
|
||||||
|
"portrait-upside-down",
|
||||||
|
"landscape-left",
|
||||||
|
"landscape-right",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Modal(props: ModalProps) {
|
||||||
|
return <NativeModal supportedOrientations={orientations} {...props} />;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
import {
|
import {
|
||||||
Children,
|
Children,
|
||||||
cloneElement,
|
cloneElement,
|
||||||
@@ -16,7 +17,6 @@ import { createPortal } from "react-dom";
|
|||||||
import {
|
import {
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Platform,
|
Platform,
|
||||||
Modal,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
View,
|
View,
|
||||||
type PressableProps,
|
type PressableProps,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
showSidebarCallout,
|
showSidebarCallout,
|
||||||
type SidebarCalloutEntry,
|
type SidebarCalloutEntry,
|
||||||
type SidebarCalloutOptions,
|
type SidebarCalloutOptions,
|
||||||
type SidebarCalloutSelectionState,
|
|
||||||
type SidebarCalloutState,
|
type SidebarCalloutState,
|
||||||
unregisterSidebarCallout,
|
unregisterSidebarCallout,
|
||||||
} from "./sidebar-callout-state";
|
} from "./sidebar-callout-state";
|
||||||
@@ -38,7 +37,7 @@ export interface SidebarCalloutsApi {
|
|||||||
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
|
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
|
||||||
|
|
||||||
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
|
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
|
||||||
const SidebarCalloutStateContext = createContext<SidebarCalloutSelectionState | null>(null);
|
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
|
||||||
|
|
||||||
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
|
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
|
||||||
void AsyncStorage.setItem(
|
void AsyncStorage.setItem(
|
||||||
@@ -114,10 +113,11 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
|
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
|
||||||
|
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarCalloutApiContext.Provider value={api}>
|
<SidebarCalloutApiContext.Provider value={api}>
|
||||||
<SidebarCalloutStateContext.Provider value={state}>
|
<SidebarCalloutStateContext.Provider value={activeCallout}>
|
||||||
{children}
|
{children}
|
||||||
</SidebarCalloutStateContext.Provider>
|
</SidebarCalloutStateContext.Provider>
|
||||||
</SidebarCalloutApiContext.Provider>
|
</SidebarCalloutApiContext.Provider>
|
||||||
@@ -132,20 +132,12 @@ export function useSidebarCallouts(): SidebarCalloutsApi {
|
|||||||
return api;
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useActiveSidebarCallout(
|
export function useActiveSidebarCallout(): SidebarCalloutEntry | null {
|
||||||
options: { nativeOnly?: boolean } = {},
|
return useContext(SidebarCalloutStateContext);
|
||||||
): SidebarCalloutEntry | null {
|
|
||||||
const state = useContext(SidebarCalloutStateContext);
|
|
||||||
const nativeOnly = options.nativeOnly === true;
|
|
||||||
return useMemo(
|
|
||||||
() => (state ? selectActiveSidebarCallout(state, { nativeOnly }) : null),
|
|
||||||
[nativeOnly, state],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The active callout. `nativeOnly` limits it to callouts marked `native`, for native apps and compact sidebars. */
|
export function SidebarCalloutViewport() {
|
||||||
export function SidebarCalloutViewport({ nativeOnly = false }: { nativeOnly?: boolean }) {
|
const activeCallout = useActiveSidebarCallout();
|
||||||
const activeCallout = useActiveSidebarCallout({ nativeOnly });
|
|
||||||
const api = useSidebarCallouts();
|
const api = useSidebarCallouts();
|
||||||
if (!activeCallout) {
|
if (!activeCallout) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -31,30 +31,6 @@ describe("sidebar callout state", () => {
|
|||||||
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
|
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects only callouts marked native for the native sidebar", () => {
|
|
||||||
let state = createSidebarCalloutState();
|
|
||||||
state = showSidebarCallout(state, {
|
|
||||||
id: "desktop-update",
|
|
||||||
priority: 300,
|
|
||||||
title: "Update available",
|
|
||||||
}).state;
|
|
||||||
state = showSidebarCallout(state, {
|
|
||||||
id: "daemon-outdated",
|
|
||||||
priority: 250,
|
|
||||||
title: "Daemon out of date",
|
|
||||||
native: true,
|
|
||||||
}).state;
|
|
||||||
|
|
||||||
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
|
|
||||||
expect(selectActiveSidebarCallout(state, { nativeOnly: true })?.title).toBe(
|
|
||||||
"Daemon out of date",
|
|
||||||
);
|
|
||||||
|
|
||||||
state = dismissSidebarCallout(state, "daemon-outdated").state;
|
|
||||||
|
|
||||||
expect(selectActiveSidebarCallout(state, { nativeOnly: true })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces a callout by id without duplicating the queue item", () => {
|
it("replaces a callout by id without duplicating the queue item", () => {
|
||||||
let state = createSidebarCalloutState();
|
let state = createSidebarCalloutState();
|
||||||
state = showSidebarCallout(state, {
|
state = showSidebarCallout(state, {
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ export interface SidebarCalloutOptions {
|
|||||||
priority?: number;
|
priority?: number;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
/** Also shown in the iOS and Android sidebar and every compact sidebar. Callouts about the desktop app leave it unset. */
|
|
||||||
native?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarCalloutEntry extends SidebarCalloutOptions {
|
export interface SidebarCalloutEntry extends SidebarCalloutOptions {
|
||||||
@@ -131,19 +129,10 @@ export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCallout
|
|||||||
return { ...state, callouts: [] };
|
return { ...state, callouts: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SidebarCalloutSelectionState = Pick<
|
|
||||||
SidebarCalloutState,
|
|
||||||
"callouts" | "dismissedKeys" | "dismissalStorageLoaded"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function selectActiveSidebarCallout(
|
export function selectActiveSidebarCallout(
|
||||||
state: SidebarCalloutSelectionState,
|
state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
|
||||||
options: { nativeOnly?: boolean } = {},
|
|
||||||
): SidebarCalloutEntry | null {
|
): SidebarCalloutEntry | null {
|
||||||
const visibleCallouts = state.callouts.filter((entry) => {
|
const visibleCallouts = state.callouts.filter((entry) => {
|
||||||
if (options.nativeOnly && entry.native !== true) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
|
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
|
||||||
if (!dismissalKey) {
|
if (!dismissalKey) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
Modal,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
type PressableStateCallbackType,
|
|
||||||
} from "react-native";
|
|
||||||
import {
|
import {
|
||||||
EditingTextInput as TextInput,
|
EditingTextInput as TextInput,
|
||||||
type EditingTextInputHandle,
|
type EditingTextInputHandle,
|
||||||
|
|||||||
@@ -832,7 +832,6 @@ describe("translation resources", () => {
|
|||||||
expect(en.common.placeholders.search).toBe("Search...");
|
expect(en.common.placeholders.search).toBe("Search...");
|
||||||
expect(en.common.empty.noResults).toBe("No results found");
|
expect(en.common.empty.noResults).toBe("No results found");
|
||||||
expect(en.common.empty.noOptionsMatchSearch).toBe("No options match your search.");
|
expect(en.common.empty.noOptionsMatchSearch).toBe("No options match your search.");
|
||||||
expect(en.toolCallDetails.subAgentActivity).toBe("Sub-agent activity");
|
|
||||||
expect(en.panels.file.failedToLoadPreview).toBe("Failed to load file preview");
|
expect(en.panels.file.failedToLoadPreview).toBe("Failed to load file preview");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2013,7 +2013,6 @@ export const ar: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "خطأ",
|
error: "خطأ",
|
||||||
empty: "لا توجد تفاصيل إضافية متاحة",
|
empty: "لا توجد تفاصيل إضافية متاحة",
|
||||||
subAgentActivity: "نشاط الوكيل الفرعي",
|
|
||||||
input: "مدخل",
|
input: "مدخل",
|
||||||
output: "الإخراج",
|
output: "الإخراج",
|
||||||
},
|
},
|
||||||
@@ -2058,18 +2057,6 @@ export const ar: TranslationResources = {
|
|||||||
sidebarCallout: {
|
sidebarCallout: {
|
||||||
dismiss: "رفض",
|
dismiss: "رفض",
|
||||||
},
|
},
|
||||||
daemonOutdated: {
|
|
||||||
title: "الخدمة قديمة",
|
|
||||||
versions: "يشغّل {{host}} الخدمة {{daemonVersion}}. إصدار هذا التطبيق {{appVersion}}.",
|
|
||||||
terminalsStayOpen: "يعيد التحديث تشغيل الخدمة. تبقى الطرفيات مفتوحة.",
|
|
||||||
terminalsClose: "يعيد التحديث تشغيل الخدمة ويغلق الطرفيات المفتوحة.",
|
|
||||||
desktopUpdateTerminals:
|
|
||||||
"يُبقي تحديث Paseo Desktop الطرفيات مفتوحة على macOS وLinux، ويغلقها على Windows.",
|
|
||||||
desktopManaged: "يدير Paseo Desktop هذه الخدمة. حدّث Paseo على ذلك الحاسوب.",
|
|
||||||
selfUpdateUnsupported: "لا يمكن لهذه الخدمة تحديث نفسها. حدّث Paseo على ذلك الحاسوب.",
|
|
||||||
update: "تحديث",
|
|
||||||
updateFailedTitle: "فشل تحديث الخدمة",
|
|
||||||
},
|
|
||||||
contextWindow: {
|
contextWindow: {
|
||||||
noData: "لا توجد بيانات للسياق",
|
noData: "لا توجد بيانات للسياق",
|
||||||
accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق",
|
accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق",
|
||||||
@@ -2169,6 +2156,7 @@ export const ar: TranslationResources = {
|
|||||||
minutes_few: "{{value}} دقائق",
|
minutes_few: "{{value}} دقائق",
|
||||||
minutes_many: "{{value}} دقيقة",
|
minutes_many: "{{value}} دقيقة",
|
||||||
minutes_other: "{{value}} دقيقة",
|
minutes_other: "{{value}} دقيقة",
|
||||||
|
pair: "{{first}} و{{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "آخر تحديث {{relativeTime}}",
|
lastUpdated: "آخر تحديث {{relativeTime}}",
|
||||||
@@ -2176,6 +2164,8 @@ export const ar: TranslationResources = {
|
|||||||
resetTimePassed: "مضى وقت إعادة التعيين",
|
resetTimePassed: "مضى وقت إعادة التعيين",
|
||||||
resettingNow: "تتم إعادة التعيين الآن",
|
resettingNow: "تتم إعادة التعيين الآن",
|
||||||
resetsIn: "تتم إعادة التعيين خلال {{duration}}",
|
resetsIn: "تتم إعادة التعيين خلال {{duration}}",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}}، {{time}}",
|
||||||
runsOutNow: "ينفد الآن",
|
runsOutNow: "ينفد الآن",
|
||||||
runsOutIn: "ينفد خلال {{duration}}",
|
runsOutIn: "ينفد خلال {{duration}}",
|
||||||
justNow: "الآن",
|
justNow: "الآن",
|
||||||
@@ -2790,6 +2780,14 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "Connect to this host to manage agents",
|
unavailable: "Connect to this host to manage agents",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "استئناف الوكلاء بعد إعادة التشغيل",
|
||||||
|
info: "إعادة فتح الوكلاء الذين كانوا يعملون عند توقف المضيف وإرسال طلب الاستئناف. ينطبق ذلك بعد إعادة التشغيل العادية والتعطّل.",
|
||||||
|
hint: "متابعة العمل المنقطع تلقائيًا.",
|
||||||
|
prompt: "طلب الاستئناف",
|
||||||
|
edit: "تحرير",
|
||||||
|
editAccessibilityLabel: "تعديل الطلب",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "تحرير الوكلاء الخاملين",
|
title: "تحرير الوكلاء الخاملين",
|
||||||
hint: "تحرير الذاكرة بعد ساعة من الخمول.",
|
hint: "تحرير الذاكرة بعد ساعة من الخمول.",
|
||||||
|
|||||||
@@ -2038,7 +2038,6 @@ export const en = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "Error",
|
error: "Error",
|
||||||
empty: "No additional details available",
|
empty: "No additional details available",
|
||||||
subAgentActivity: "Sub-agent activity",
|
|
||||||
input: "Input",
|
input: "Input",
|
||||||
output: "Output",
|
output: "Output",
|
||||||
},
|
},
|
||||||
@@ -2083,18 +2082,6 @@ export const en = {
|
|||||||
sidebarCallout: {
|
sidebarCallout: {
|
||||||
dismiss: "Dismiss",
|
dismiss: "Dismiss",
|
||||||
},
|
},
|
||||||
daemonOutdated: {
|
|
||||||
title: "Daemon out of date",
|
|
||||||
versions: "{{host}} runs daemon {{daemonVersion}}. This app is {{appVersion}}.",
|
|
||||||
terminalsStayOpen: "Updating restarts the daemon. Terminals stay open.",
|
|
||||||
terminalsClose: "Updating restarts the daemon and closes open terminals.",
|
|
||||||
desktopUpdateTerminals:
|
|
||||||
"Updating Paseo Desktop keeps terminals open on macOS and Linux, and closes them on Windows.",
|
|
||||||
desktopManaged: "Paseo Desktop manages this daemon. Update Paseo on that computer.",
|
|
||||||
selfUpdateUnsupported: "This daemon can't update itself. Update Paseo on that computer.",
|
|
||||||
update: "Update",
|
|
||||||
updateFailedTitle: "Daemon update failed",
|
|
||||||
},
|
|
||||||
contextWindow: {
|
contextWindow: {
|
||||||
noData: "No context data",
|
noData: "No context data",
|
||||||
accessibilityNoData: "Context window: No context data",
|
accessibilityNoData: "Context window: No context data",
|
||||||
@@ -2176,15 +2163,18 @@ export const en = {
|
|||||||
days: "{{value}}d",
|
days: "{{value}}d",
|
||||||
hours: "{{value}}h",
|
hours: "{{value}}h",
|
||||||
minutes: "{{value}}m",
|
minutes: "{{value}}m",
|
||||||
|
pair: "{{first}} {{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "Last updated {{relativeTime}}",
|
lastUpdated: "Last updated {{relativeTime}}",
|
||||||
noSuccessfulUpdate: "No successful update",
|
noSuccessfulUpdate: "No successful update",
|
||||||
resetTimePassed: "Reset time passed",
|
resetTimePassed: "Reset time passed",
|
||||||
resettingNow: "resetting now",
|
resettingNow: "resetting now",
|
||||||
resetsIn: "resets {{duration}}",
|
resetsIn: "resets in {{duration}}",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}}, {{time}}",
|
||||||
runsOutNow: "runs out now",
|
runsOutNow: "runs out now",
|
||||||
runsOutIn: "runs out {{duration}}",
|
runsOutIn: "runs out in {{duration}}",
|
||||||
justNow: "just now",
|
justNow: "just now",
|
||||||
daysAgo: "{{value}}d ago",
|
daysAgo: "{{value}}d ago",
|
||||||
hoursAgo: "{{value}}h ago",
|
hoursAgo: "{{value}}h ago",
|
||||||
@@ -2882,6 +2872,14 @@ export const en = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "Connect to this host to manage agents",
|
unavailable: "Connect to this host to manage agents",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "Resume agents after restart",
|
||||||
|
info: "Reopen agents that were working when the host stopped and send the resume prompt. Applies after graceful restarts and crashes.",
|
||||||
|
hint: "Continue interrupted work automatically.",
|
||||||
|
prompt: "Resume prompt",
|
||||||
|
edit: "Edit",
|
||||||
|
editAccessibilityLabel: "Edit prompt",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "Release idle agents",
|
title: "Release idle agents",
|
||||||
hint: "Free up memory after an hour of inactivity.",
|
hint: "Free up memory after an hour of inactivity.",
|
||||||
|
|||||||
@@ -2061,7 +2061,6 @@ export const es: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "Error",
|
error: "Error",
|
||||||
empty: "No hay detalles adicionales disponibles",
|
empty: "No hay detalles adicionales disponibles",
|
||||||
subAgentActivity: "Actividad de subagente",
|
|
||||||
input: "Aporte",
|
input: "Aporte",
|
||||||
output: "Producción",
|
output: "Producción",
|
||||||
},
|
},
|
||||||
@@ -2106,19 +2105,6 @@ export const es: TranslationResources = {
|
|||||||
sidebarCallout: {
|
sidebarCallout: {
|
||||||
dismiss: "Despedir",
|
dismiss: "Despedir",
|
||||||
},
|
},
|
||||||
daemonOutdated: {
|
|
||||||
title: "Daemon desactualizado",
|
|
||||||
versions: "{{host}} ejecuta el daemon {{daemonVersion}}. Esta app es {{appVersion}}.",
|
|
||||||
terminalsStayOpen: "Actualizar reinicia el daemon. Las terminales siguen abiertas.",
|
|
||||||
terminalsClose: "Actualizar reinicia el daemon y cierra las terminales abiertas.",
|
|
||||||
desktopUpdateTerminals:
|
|
||||||
"Actualizar Paseo Desktop mantiene las terminales abiertas en macOS y Linux, y las cierra en Windows.",
|
|
||||||
desktopManaged: "Paseo Desktop administra este daemon. Actualiza Paseo en esa computadora.",
|
|
||||||
selfUpdateUnsupported:
|
|
||||||
"Este daemon no puede actualizarse solo. Actualiza Paseo en esa computadora.",
|
|
||||||
update: "Actualizar",
|
|
||||||
updateFailedTitle: "No se pudo actualizar el daemon",
|
|
||||||
},
|
|
||||||
contextWindow: {
|
contextWindow: {
|
||||||
noData: "No hay datos de contexto",
|
noData: "No hay datos de contexto",
|
||||||
accessibilityNoData: "Ventana de contexto: No hay datos de contexto",
|
accessibilityNoData: "Ventana de contexto: No hay datos de contexto",
|
||||||
@@ -2200,6 +2186,7 @@ export const es: TranslationResources = {
|
|||||||
days: "{{value}} d",
|
days: "{{value}} d",
|
||||||
hours: "{{value}} h",
|
hours: "{{value}} h",
|
||||||
minutes: "{{value}} min",
|
minutes: "{{value}} min",
|
||||||
|
pair: "{{first}} {{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "Última actualización {{relativeTime}}",
|
lastUpdated: "Última actualización {{relativeTime}}",
|
||||||
@@ -2207,6 +2194,8 @@ export const es: TranslationResources = {
|
|||||||
resetTimePassed: "Pasó la hora de restablecimiento",
|
resetTimePassed: "Pasó la hora de restablecimiento",
|
||||||
resettingNow: "se restablece ahora",
|
resettingNow: "se restablece ahora",
|
||||||
resetsIn: "se restablece en {{duration}}",
|
resetsIn: "se restablece en {{duration}}",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}}, {{time}}",
|
||||||
runsOutNow: "se agota ahora",
|
runsOutNow: "se agota ahora",
|
||||||
runsOutIn: "se agota en {{duration}}",
|
runsOutIn: "se agota en {{duration}}",
|
||||||
justNow: "ahora mismo",
|
justNow: "ahora mismo",
|
||||||
@@ -2813,6 +2802,14 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "Connect to this host to manage agents",
|
unavailable: "Connect to this host to manage agents",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "Reanudar agentes tras reiniciar",
|
||||||
|
info: "Vuelve a abrir los agentes que estaban trabajando cuando se detuvo el host y envía el mensaje de reanudación. Se aplica tras reinicios normales y fallos.",
|
||||||
|
hint: "Continuar automáticamente el trabajo interrumpido.",
|
||||||
|
prompt: "Mensaje de reanudación",
|
||||||
|
edit: "Editar",
|
||||||
|
editAccessibilityLabel: "Editar mensaje",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "Liberar agentes inactivos",
|
title: "Liberar agentes inactivos",
|
||||||
hint: "Libera memoria tras una hora de inactividad.",
|
hint: "Libera memoria tras una hora de inactividad.",
|
||||||
|
|||||||
@@ -2060,7 +2060,6 @@ export const fr: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "Erreur",
|
error: "Erreur",
|
||||||
empty: "Aucun détail supplémentaire disponible",
|
empty: "Aucun détail supplémentaire disponible",
|
||||||
subAgentActivity: "Activité du sous-agent",
|
|
||||||
input: "Entrée",
|
input: "Entrée",
|
||||||
output: "Sortie",
|
output: "Sortie",
|
||||||
},
|
},
|
||||||
@@ -2105,19 +2104,6 @@ export const fr: TranslationResources = {
|
|||||||
sidebarCallout: {
|
sidebarCallout: {
|
||||||
dismiss: "Ignorer",
|
dismiss: "Ignorer",
|
||||||
},
|
},
|
||||||
daemonOutdated: {
|
|
||||||
title: "Daemon obsolète",
|
|
||||||
versions: "{{host}} exécute le daemon {{daemonVersion}}. Cette app est en {{appVersion}}.",
|
|
||||||
terminalsStayOpen: "La mise à jour redémarre le daemon. Les terminaux restent ouverts.",
|
|
||||||
terminalsClose: "La mise à jour redémarre le daemon et ferme les terminaux ouverts.",
|
|
||||||
desktopUpdateTerminals:
|
|
||||||
"La mise à jour de Paseo Desktop garde les terminaux ouverts sur macOS et Linux, et les ferme sur Windows.",
|
|
||||||
desktopManaged: "Paseo Desktop gère ce daemon. Mettez à jour Paseo sur cet ordinateur.",
|
|
||||||
selfUpdateUnsupported:
|
|
||||||
"Ce daemon ne peut pas se mettre à jour lui-même. Mettez à jour Paseo sur cet ordinateur.",
|
|
||||||
update: "Mettre à jour",
|
|
||||||
updateFailedTitle: "Échec de la mise à jour du daemon",
|
|
||||||
},
|
|
||||||
contextWindow: {
|
contextWindow: {
|
||||||
noData: "Aucune donnée de contexte",
|
noData: "Aucune donnée de contexte",
|
||||||
accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte",
|
accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte",
|
||||||
@@ -2200,6 +2186,7 @@ export const fr: TranslationResources = {
|
|||||||
days: "{{value}} j",
|
days: "{{value}} j",
|
||||||
hours: "{{value}} h",
|
hours: "{{value}} h",
|
||||||
minutes: "{{value}} min",
|
minutes: "{{value}} min",
|
||||||
|
pair: "{{first}} {{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "Dernière mise à jour {{relativeTime}}",
|
lastUpdated: "Dernière mise à jour {{relativeTime}}",
|
||||||
@@ -2207,6 +2194,8 @@ export const fr: TranslationResources = {
|
|||||||
resetTimePassed: "Heure de réinitialisation dépassée",
|
resetTimePassed: "Heure de réinitialisation dépassée",
|
||||||
resettingNow: "réinitialisation en cours",
|
resettingNow: "réinitialisation en cours",
|
||||||
resetsIn: "se réinitialise dans {{duration}}",
|
resetsIn: "se réinitialise dans {{duration}}",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}}, {{time}}",
|
||||||
runsOutNow: "s’épuise maintenant",
|
runsOutNow: "s’épuise maintenant",
|
||||||
runsOutIn: "s’épuise dans {{duration}}",
|
runsOutIn: "s’épuise dans {{duration}}",
|
||||||
justNow: "à l’instant",
|
justNow: "à l’instant",
|
||||||
@@ -2816,6 +2805,14 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "Connectez-vous à cet hôte pour gérer les agents",
|
unavailable: "Connectez-vous à cet hôte pour gérer les agents",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "Reprendre les agents après un redémarrage",
|
||||||
|
info: "Rouvre les agents qui travaillaient lorsque l’hôte s’est arrêté et envoie le message de reprise. S’applique après un redémarrage normal ou un plantage.",
|
||||||
|
hint: "Continuer automatiquement le travail interrompu.",
|
||||||
|
prompt: "Message de reprise",
|
||||||
|
edit: "Modifier",
|
||||||
|
editAccessibilityLabel: "Modifier le message",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "Libérer les agents inactifs",
|
title: "Libérer les agents inactifs",
|
||||||
hint: "Libère de la mémoire après une heure d’inactivité.",
|
hint: "Libère de la mémoire après une heure d’inactivité.",
|
||||||
|
|||||||
@@ -2033,7 +2033,6 @@ export const ja: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "エラー",
|
error: "エラー",
|
||||||
empty: "追加の詳細はありません",
|
empty: "追加の詳細はありません",
|
||||||
subAgentActivity: "サブエージェントアクティビティ",
|
|
||||||
input: "入力",
|
input: "入力",
|
||||||
output: "出力",
|
output: "出力",
|
||||||
},
|
},
|
||||||
@@ -2078,20 +2077,6 @@ export const ja: TranslationResources = {
|
|||||||
sidebarCallout: {
|
sidebarCallout: {
|
||||||
dismiss: "閉じる",
|
dismiss: "閉じる",
|
||||||
},
|
},
|
||||||
daemonOutdated: {
|
|
||||||
title: "デーモンが古くなっています",
|
|
||||||
versions: "{{host}}のデーモンは{{daemonVersion}}です。このアプリは{{appVersion}}です。",
|
|
||||||
terminalsStayOpen: "更新するとデーモンが再起動します。ターミナルは開いたままです。",
|
|
||||||
terminalsClose: "更新するとデーモンが再起動し、開いているターミナルは閉じられます。",
|
|
||||||
desktopUpdateTerminals:
|
|
||||||
"Paseo Desktop を更新すると、macOS と Linux ではターミナルは開いたままですが、Windows では閉じられます。",
|
|
||||||
desktopManaged:
|
|
||||||
"このデーモンはPaseo Desktopによって管理されています。そのコンピューターでPaseoを更新してください。",
|
|
||||||
selfUpdateUnsupported:
|
|
||||||
"このデーモンは自身を更新できません。そのコンピューターでPaseoを更新してください。",
|
|
||||||
update: "更新",
|
|
||||||
updateFailedTitle: "デーモンを更新できませんでした",
|
|
||||||
},
|
|
||||||
contextWindow: {
|
contextWindow: {
|
||||||
noData: "コンテキストデータがありません",
|
noData: "コンテキストデータがありません",
|
||||||
accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません",
|
accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません",
|
||||||
@@ -2174,6 +2159,7 @@ export const ja: TranslationResources = {
|
|||||||
days: "{{value}}日",
|
days: "{{value}}日",
|
||||||
hours: "{{value}}時間",
|
hours: "{{value}}時間",
|
||||||
minutes: "{{value}}分",
|
minutes: "{{value}}分",
|
||||||
|
pair: "{{first}}{{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "最終更新: {{relativeTime}}",
|
lastUpdated: "最終更新: {{relativeTime}}",
|
||||||
@@ -2181,6 +2167,8 @@ export const ja: TranslationResources = {
|
|||||||
resetTimePassed: "リセット時刻を過ぎています",
|
resetTimePassed: "リセット時刻を過ぎています",
|
||||||
resettingNow: "まもなくリセット",
|
resettingNow: "まもなくリセット",
|
||||||
resetsIn: "{{duration}}後にリセット",
|
resetsIn: "{{duration}}後にリセット",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}} {{time}}",
|
||||||
runsOutNow: "まもなく上限に到達",
|
runsOutNow: "まもなく上限に到達",
|
||||||
runsOutIn: "{{duration}}後に上限に到達",
|
runsOutIn: "{{duration}}後に上限に到達",
|
||||||
justNow: "たった今",
|
justNow: "たった今",
|
||||||
@@ -2781,6 +2769,14 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "エージェントを管理するにはこのホストに接続してください",
|
unavailable: "エージェントを管理するにはこのホストに接続してください",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "再起動後にエージェントを再開",
|
||||||
|
info: "ホストの停止時に作業中だったエージェントを開き直し、再開メッセージを送信します。通常の再起動とクラッシュの両方に適用されます。",
|
||||||
|
hint: "中断された作業を自動的に続行します。",
|
||||||
|
prompt: "再開メッセージ",
|
||||||
|
edit: "編集",
|
||||||
|
editAccessibilityLabel: "メッセージを編集",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "アイドル状態のエージェントを解放",
|
title: "アイドル状態のエージェントを解放",
|
||||||
hint: "1 時間のアイドル状態の後にメモリを解放します。",
|
hint: "1 時間のアイドル状態の後にメモリを解放します。",
|
||||||
|
|||||||
@@ -2023,7 +2023,6 @@ export const ko: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "오류",
|
error: "오류",
|
||||||
empty: "추가 세부 정보가 없습니다",
|
empty: "추가 세부 정보가 없습니다",
|
||||||
subAgentActivity: "서브에이전트 활동",
|
|
||||||
input: "입력",
|
input: "입력",
|
||||||
output: "출력",
|
output: "출력",
|
||||||
},
|
},
|
||||||
@@ -2068,20 +2067,6 @@ export const ko: TranslationResources = {
|
|||||||
sidebarCallout: {
|
sidebarCallout: {
|
||||||
dismiss: "닫기",
|
dismiss: "닫기",
|
||||||
},
|
},
|
||||||
daemonOutdated: {
|
|
||||||
title: "데몬이 오래되었습니다",
|
|
||||||
versions: "{{host}}의 데몬은 {{daemonVersion}}입니다. 이 앱은 {{appVersion}}입니다.",
|
|
||||||
terminalsStayOpen: "업데이트하면 데몬이 다시 시작됩니다. 터미널은 열린 상태로 유지됩니다.",
|
|
||||||
terminalsClose: "업데이트하면 데몬이 다시 시작되고 열려 있는 터미널이 닫힙니다.",
|
|
||||||
desktopUpdateTerminals:
|
|
||||||
"Paseo Desktop을 업데이트하면 macOS와 Linux에서는 터미널이 열린 상태로 유지되고, Windows에서는 닫힙니다.",
|
|
||||||
desktopManaged:
|
|
||||||
"이 데몬은 Paseo Desktop에서 관리됩니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
|
|
||||||
selfUpdateUnsupported:
|
|
||||||
"이 데몬은 스스로 업데이트할 수 없습니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
|
|
||||||
update: "업데이트",
|
|
||||||
updateFailedTitle: "데몬 업데이트 실패",
|
|
||||||
},
|
|
||||||
contextWindow: {
|
contextWindow: {
|
||||||
noData: "컨텍스트 데이터 없음",
|
noData: "컨텍스트 데이터 없음",
|
||||||
accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음",
|
accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음",
|
||||||
@@ -2163,6 +2148,7 @@ export const ko: TranslationResources = {
|
|||||||
days: "{{value}}일",
|
days: "{{value}}일",
|
||||||
hours: "{{value}}시간",
|
hours: "{{value}}시간",
|
||||||
minutes: "{{value}}분",
|
minutes: "{{value}}분",
|
||||||
|
pair: "{{first}} {{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "마지막 업데이트: {{relativeTime}}",
|
lastUpdated: "마지막 업데이트: {{relativeTime}}",
|
||||||
@@ -2170,6 +2156,8 @@ export const ko: TranslationResources = {
|
|||||||
resetTimePassed: "초기화 시간이 지났습니다",
|
resetTimePassed: "초기화 시간이 지났습니다",
|
||||||
resettingNow: "지금 재설정 중",
|
resettingNow: "지금 재설정 중",
|
||||||
resetsIn: "{{duration}} 후 재설정",
|
resetsIn: "{{duration}} 후 재설정",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}} {{time}}",
|
||||||
runsOutNow: "지금 한도에 도달",
|
runsOutNow: "지금 한도에 도달",
|
||||||
runsOutIn: "{{duration}} 후 한도에 도달",
|
runsOutIn: "{{duration}} 후 한도에 도달",
|
||||||
justNow: "방금",
|
justNow: "방금",
|
||||||
@@ -2769,6 +2757,14 @@ export const ko: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
|
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "재시작 후 에이전트 재개",
|
||||||
|
info: "호스트가 중지될 때 작업 중이던 에이전트를 다시 열고 재개 메시지를 보냅니다. 정상 재시작과 비정상 종료 후 모두 적용됩니다.",
|
||||||
|
hint: "중단된 작업을 자동으로 계속합니다.",
|
||||||
|
prompt: "재개 메시지",
|
||||||
|
edit: "편집",
|
||||||
|
editAccessibilityLabel: "메시지 편집",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "유휴 에이전트 해제",
|
title: "유휴 에이전트 해제",
|
||||||
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
|
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
|
||||||
|
|||||||
@@ -2046,7 +2046,6 @@ export const ptBR: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "Erro",
|
error: "Erro",
|
||||||
empty: "Nenhum detalhe adicional disponível",
|
empty: "Nenhum detalhe adicional disponível",
|
||||||
subAgentActivity: "Atividade do subagente",
|
|
||||||
input: "Entrada",
|
input: "Entrada",
|
||||||
output: "Saída",
|
output: "Saída",
|
||||||
},
|
},
|
||||||
@@ -2091,19 +2090,6 @@ export const ptBR: TranslationResources = {
|
|||||||
sidebarCallout: {
|
sidebarCallout: {
|
||||||
dismiss: "Dispensar",
|
dismiss: "Dispensar",
|
||||||
},
|
},
|
||||||
daemonOutdated: {
|
|
||||||
title: "Daemon desatualizado",
|
|
||||||
versions: "{{host}} executa o daemon {{daemonVersion}}. Este app é {{appVersion}}.",
|
|
||||||
terminalsStayOpen: "Atualizar reinicia o daemon. Os terminais continuam abertos.",
|
|
||||||
terminalsClose: "Atualizar reinicia o daemon e fecha os terminais abertos.",
|
|
||||||
desktopUpdateTerminals:
|
|
||||||
"Atualizar o Paseo Desktop mantém os terminais abertos no macOS e no Linux, e os fecha no Windows.",
|
|
||||||
desktopManaged: "O Paseo Desktop gerencia este daemon. Atualize o Paseo nesse computador.",
|
|
||||||
selfUpdateUnsupported:
|
|
||||||
"Este daemon não consegue se atualizar sozinho. Atualize o Paseo nesse computador.",
|
|
||||||
update: "Atualizar",
|
|
||||||
updateFailedTitle: "Falha ao atualizar o daemon",
|
|
||||||
},
|
|
||||||
contextWindow: {
|
contextWindow: {
|
||||||
noData: "Sem dados de contexto",
|
noData: "Sem dados de contexto",
|
||||||
accessibilityNoData: "Janela de contexto: sem dados de contexto",
|
accessibilityNoData: "Janela de contexto: sem dados de contexto",
|
||||||
@@ -2185,6 +2171,7 @@ export const ptBR: TranslationResources = {
|
|||||||
days: "{{value}} d",
|
days: "{{value}} d",
|
||||||
hours: "{{value}} h",
|
hours: "{{value}} h",
|
||||||
minutes: "{{value}} min",
|
minutes: "{{value}} min",
|
||||||
|
pair: "{{first}} {{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "Última atualização {{relativeTime}}",
|
lastUpdated: "Última atualização {{relativeTime}}",
|
||||||
@@ -2192,6 +2179,8 @@ export const ptBR: TranslationResources = {
|
|||||||
resetTimePassed: "O horário de redefinição passou",
|
resetTimePassed: "O horário de redefinição passou",
|
||||||
resettingNow: "redefinindo agora",
|
resettingNow: "redefinindo agora",
|
||||||
resetsIn: "redefine em {{duration}}",
|
resetsIn: "redefine em {{duration}}",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}}, {{time}}",
|
||||||
runsOutNow: "esgota agora",
|
runsOutNow: "esgota agora",
|
||||||
runsOutIn: "esgota em {{duration}}",
|
runsOutIn: "esgota em {{duration}}",
|
||||||
justNow: "agora mesmo",
|
justNow: "agora mesmo",
|
||||||
@@ -2795,6 +2784,14 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "Conecte-se a este host para gerenciar agentes",
|
unavailable: "Conecte-se a este host para gerenciar agentes",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "Retomar agentes após reiniciar",
|
||||||
|
info: "Reabre os agentes que estavam trabalhando quando o host parou e envia a mensagem de retomada. Aplica-se após reinicializações normais e falhas.",
|
||||||
|
hint: "Continuar automaticamente o trabalho interrompido.",
|
||||||
|
prompt: "Mensagem de retomada",
|
||||||
|
edit: "Editar",
|
||||||
|
editAccessibilityLabel: "Editar mensagem",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "Liberar agentes ociosos",
|
title: "Liberar agentes ociosos",
|
||||||
hint: "Libera memória após uma hora de inatividade.",
|
hint: "Libera memória após uma hora de inatividade.",
|
||||||
|
|||||||
@@ -2046,7 +2046,6 @@ export const ru: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "Ошибка",
|
error: "Ошибка",
|
||||||
empty: "Дополнительные сведения отсутствуют",
|
empty: "Дополнительные сведения отсутствуют",
|
||||||
subAgentActivity: "Активность субагента",
|
|
||||||
input: "Входные данные",
|
input: "Входные данные",
|
||||||
output: "Выходные данные",
|
output: "Выходные данные",
|
||||||
},
|
},
|
||||||
@@ -2091,18 +2090,6 @@ export const ru: TranslationResources = {
|
|||||||
sidebarCallout: {
|
sidebarCallout: {
|
||||||
dismiss: "Закрыть",
|
dismiss: "Закрыть",
|
||||||
},
|
},
|
||||||
daemonOutdated: {
|
|
||||||
title: "Демон устарел",
|
|
||||||
versions: "На {{host}} работает демон {{daemonVersion}}. Версия приложения: {{appVersion}}.",
|
|
||||||
terminalsStayOpen: "Обновление перезапустит демон. Терминалы останутся открытыми.",
|
|
||||||
terminalsClose: "Обновление перезапустит демон и закроет открытые терминалы.",
|
|
||||||
desktopUpdateTerminals:
|
|
||||||
"Обновление Paseo Desktop оставляет терминалы открытыми в macOS и Linux и закрывает их в Windows.",
|
|
||||||
desktopManaged: "Этим демоном управляет Paseo Desktop. Обновите Paseo на том компьютере.",
|
|
||||||
selfUpdateUnsupported: "Этот демон не может обновиться сам. Обновите Paseo на том компьютере.",
|
|
||||||
update: "Обновить",
|
|
||||||
updateFailedTitle: "Не удалось обновить демон",
|
|
||||||
},
|
|
||||||
contextWindow: {
|
contextWindow: {
|
||||||
noData: "Нет данных о контексте",
|
noData: "Нет данных о контексте",
|
||||||
accessibilityNoData: "Контекстное окно: нет данных о контексте",
|
accessibilityNoData: "Контекстное окно: нет данных о контексте",
|
||||||
@@ -2184,6 +2171,7 @@ export const ru: TranslationResources = {
|
|||||||
days: "{{value}} дн.",
|
days: "{{value}} дн.",
|
||||||
hours: "{{value}} ч",
|
hours: "{{value}} ч",
|
||||||
minutes: "{{value}} мин",
|
minutes: "{{value}} мин",
|
||||||
|
pair: "{{first}} {{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "Последнее обновление {{relativeTime}}",
|
lastUpdated: "Последнее обновление {{relativeTime}}",
|
||||||
@@ -2191,6 +2179,8 @@ export const ru: TranslationResources = {
|
|||||||
resetTimePassed: "Время сброса прошло",
|
resetTimePassed: "Время сброса прошло",
|
||||||
resettingNow: "сброс сейчас",
|
resettingNow: "сброс сейчас",
|
||||||
resetsIn: "сброс через {{duration}}",
|
resetsIn: "сброс через {{duration}}",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}}, {{time}}",
|
||||||
runsOutNow: "закончится сейчас",
|
runsOutNow: "закончится сейчас",
|
||||||
runsOutIn: "закончится через {{duration}}",
|
runsOutIn: "закончится через {{duration}}",
|
||||||
justNow: "только что",
|
justNow: "только что",
|
||||||
@@ -2804,6 +2794,14 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
|
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "Возобновлять агентов после перезапуска",
|
||||||
|
info: "Открывает агентов, которые работали при остановке хоста, и отправляет сообщение для продолжения. Применяется после обычного перезапуска и сбоя.",
|
||||||
|
hint: "Автоматически продолжать прерванную работу.",
|
||||||
|
prompt: "Сообщение для продолжения",
|
||||||
|
edit: "Редактировать",
|
||||||
|
editAccessibilityLabel: "Изменить сообщение",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "Освобождать неактивных агентов",
|
title: "Освобождать неактивных агентов",
|
||||||
hint: "Освобождает память после часа бездействия.",
|
hint: "Освобождает память после часа бездействия.",
|
||||||
|
|||||||
@@ -1991,7 +1991,6 @@ export const zhCN: TranslationResources = {
|
|||||||
toolCallDetails: {
|
toolCallDetails: {
|
||||||
error: "错误",
|
error: "错误",
|
||||||
empty: "没有可用的更多详情",
|
empty: "没有可用的更多详情",
|
||||||
subAgentActivity: "Sub-agent 活动",
|
|
||||||
input: "输入",
|
input: "输入",
|
||||||
output: "输出",
|
output: "输出",
|
||||||
},
|
},
|
||||||
@@ -2036,18 +2035,6 @@ export const zhCN: TranslationResources = {
|
|||||||
sidebarCallout: {
|
sidebarCallout: {
|
||||||
dismiss: "关闭",
|
dismiss: "关闭",
|
||||||
},
|
},
|
||||||
daemonOutdated: {
|
|
||||||
title: "守护进程版本过旧",
|
|
||||||
versions: "{{host}} 运行的守护进程为 {{daemonVersion}},此应用为 {{appVersion}}。",
|
|
||||||
terminalsStayOpen: "更新会重启守护进程,终端将保持打开。",
|
|
||||||
terminalsClose: "更新会重启守护进程,并关闭已打开的终端。",
|
|
||||||
desktopUpdateTerminals:
|
|
||||||
"更新 Paseo Desktop 时,macOS 和 Linux 上的终端保持打开,Windows 上的终端会被关闭。",
|
|
||||||
desktopManaged: "此守护进程由 Paseo Desktop 管理。请在那台电脑上更新 Paseo。",
|
|
||||||
selfUpdateUnsupported: "此守护进程无法自行更新。请在那台电脑上更新 Paseo。",
|
|
||||||
update: "更新",
|
|
||||||
updateFailedTitle: "守护进程更新失败",
|
|
||||||
},
|
|
||||||
contextWindow: {
|
contextWindow: {
|
||||||
noData: "暂无上下文数据",
|
noData: "暂无上下文数据",
|
||||||
accessibilityNoData: "上下文窗口:暂无上下文数据",
|
accessibilityNoData: "上下文窗口:暂无上下文数据",
|
||||||
@@ -2129,6 +2116,7 @@ export const zhCN: TranslationResources = {
|
|||||||
days: "{{value}}天",
|
days: "{{value}}天",
|
||||||
hours: "{{value}}小时",
|
hours: "{{value}}小时",
|
||||||
minutes: "{{value}}分钟",
|
minutes: "{{value}}分钟",
|
||||||
|
pair: "{{first}}{{second}}",
|
||||||
},
|
},
|
||||||
timing: {
|
timing: {
|
||||||
lastUpdated: "上次更新于 {{relativeTime}}",
|
lastUpdated: "上次更新于 {{relativeTime}}",
|
||||||
@@ -2136,6 +2124,8 @@ export const zhCN: TranslationResources = {
|
|||||||
resetTimePassed: "重置时间已过",
|
resetTimePassed: "重置时间已过",
|
||||||
resettingNow: "正在重置",
|
resettingNow: "正在重置",
|
||||||
resetsIn: "将在 {{duration}}后重置",
|
resetsIn: "将在 {{duration}}后重置",
|
||||||
|
resetsAtWeekday: "{{weekday}} {{time}}",
|
||||||
|
resetsAtDate: "{{date}} {{time}}",
|
||||||
runsOutNow: "即将用尽",
|
runsOutNow: "即将用尽",
|
||||||
runsOutIn: "将在 {{duration}}后用尽",
|
runsOutIn: "将在 {{duration}}后用尽",
|
||||||
justNow: "刚刚",
|
justNow: "刚刚",
|
||||||
@@ -2725,6 +2715,14 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
unavailable: "连接到这个 Host 以管理 Agent",
|
unavailable: "连接到这个 Host 以管理 Agent",
|
||||||
|
resumeOnStartup: {
|
||||||
|
title: "重启后恢复智能体",
|
||||||
|
info: "重新打开主机停止时正在工作的智能体,并发送恢复提示。适用于正常重启和崩溃后的恢复。",
|
||||||
|
hint: "自动继续被中断的工作。",
|
||||||
|
prompt: "恢复提示",
|
||||||
|
edit: "编辑",
|
||||||
|
editAccessibilityLabel: "编辑提示",
|
||||||
|
},
|
||||||
idleRuntimeCleanup: {
|
idleRuntimeCleanup: {
|
||||||
title: "释放空闲 Agent",
|
title: "释放空闲 Agent",
|
||||||
hint: "空闲一小时后释放内存。",
|
hint: "空闲一小时后释放内存。",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import { ICON_SIZE } from "@/styles/theme";
|
|||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { useProviderIcon } from "@/components/provider-icons";
|
import { useProviderIcon } from "@/components/provider-icons";
|
||||||
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
||||||
|
import { RestartRecoveryCard } from "./restart-recovery-card";
|
||||||
import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card";
|
import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card";
|
||||||
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
|
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
|
||||||
|
|
||||||
@@ -292,6 +293,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
|
|||||||
<InjectPaseoToolsCard serverId={serverId} />
|
<InjectPaseoToolsCard serverId={serverId} />
|
||||||
<BrowserToolsOptInCard serverId={serverId} />
|
<BrowserToolsOptInCard serverId={serverId} />
|
||||||
<AppendSystemPromptCard serverId={serverId} />
|
<AppendSystemPromptCard serverId={serverId} />
|
||||||
|
<RestartRecoveryCard serverId={serverId} />
|
||||||
<IdleRuntimeCleanupCard serverId={serverId} />
|
<IdleRuntimeCleanupCard serverId={serverId} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||||
|
import { SettingsInfoTip } from "@/components/settings/headings/settings-info-tip";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||||
|
import { useHostFeature } from "@/runtime/host-features";
|
||||||
|
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import { settingsStyles } from "@/styles/settings";
|
||||||
|
|
||||||
|
const DEFAULT_RESUME_PROMPT = "Resume working.";
|
||||||
|
|
||||||
|
// COMPAT(restartRecovery): added in v0.14.0, remove gate after 2027-04-28.
|
||||||
|
export function RestartRecoveryCard({ serverId }: { serverId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const isSupported = useHostFeature(serverId, "resumeOnStartupSetting");
|
||||||
|
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (next: { resumeOnStartup?: boolean; resumePrompt?: string }) => {
|
||||||
|
const result = await patchConfig(next);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleValueChange = useCallback(
|
||||||
|
(next: boolean) => {
|
||||||
|
mutation.mutate({ resumeOnStartup: next });
|
||||||
|
},
|
||||||
|
[mutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(() => {
|
||||||
|
mutation.reset();
|
||||||
|
setIsEditing(true);
|
||||||
|
}, [mutation]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (mutation.isPending) return;
|
||||||
|
mutation.reset();
|
||||||
|
setIsEditing(false);
|
||||||
|
}, [mutation]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
(prompt: string) => {
|
||||||
|
mutation.mutate({ resumePrompt: prompt }, { onSuccess: () => setIsEditing(false) });
|
||||||
|
},
|
||||||
|
[mutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isConnected || !isSupported || !config) return null;
|
||||||
|
|
||||||
|
const isEnabled = config.resumeOnStartup !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={settingsStyles.card} testID="host-page-restart-recovery-card">
|
||||||
|
<View style={settingsStyles.row}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>
|
||||||
|
{t("settings.host.agents.resumeOnStartup.title")}
|
||||||
|
</Text>
|
||||||
|
<SettingsInfoTip
|
||||||
|
title={t("settings.host.agents.resumeOnStartup.title")}
|
||||||
|
info={t("settings.host.agents.resumeOnStartup.info")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={settingsStyles.rowHint}>
|
||||||
|
{t("settings.host.agents.resumeOnStartup.hint")}
|
||||||
|
</Text>
|
||||||
|
{mutation.error && !isEditing ? (
|
||||||
|
<Text style={settingsStyles.rowError} testID="host-page-restart-recovery-error">
|
||||||
|
{mutation.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View style={styles.trailing}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleOpen}
|
||||||
|
disabled={!isEnabled || mutation.isPending}
|
||||||
|
accessibilityLabel={t("settings.host.agents.resumeOnStartup.editAccessibilityLabel")}
|
||||||
|
testID="host-page-restart-recovery-edit"
|
||||||
|
>
|
||||||
|
{t("settings.host.agents.resumeOnStartup.edit")}
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
value={isEnabled}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
accessibilityLabel={t("settings.host.agents.resumeOnStartup.title")}
|
||||||
|
testID="host-page-restart-recovery-switch"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<ResumePromptSheet
|
||||||
|
initialPrompt={config.resumePrompt ?? DEFAULT_RESUME_PROMPT}
|
||||||
|
isSaving={mutation.isPending}
|
||||||
|
error={mutation.error?.message ?? null}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResumePromptSheet({
|
||||||
|
initialPrompt,
|
||||||
|
isSaving,
|
||||||
|
error,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
initialPrompt: string;
|
||||||
|
isSaving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onSave: (prompt: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [draft, setDraft] = useState(initialPrompt);
|
||||||
|
const header = useMemo<SheetHeader>(
|
||||||
|
() => ({ title: t("settings.host.agents.resumeOnStartup.prompt") }),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
const canSave = draft !== initialPrompt && draft.trim().length > 0 && !isSaving;
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => onSave(draft), [draft, onSave]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdaptiveModalSheet
|
||||||
|
header={header}
|
||||||
|
visible
|
||||||
|
onClose={onClose}
|
||||||
|
testID="host-page-restart-recovery-sheet"
|
||||||
|
desktopMaxWidth={560}
|
||||||
|
>
|
||||||
|
<SettingsTextAreaCard
|
||||||
|
testID="host-page-restart-recovery-prompt-input"
|
||||||
|
accessibilityLabel={t("settings.host.agents.resumeOnStartup.prompt")}
|
||||||
|
value={draft}
|
||||||
|
onChangeText={setDraft}
|
||||||
|
placeholder={DEFAULT_RESUME_PROMPT}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<Text style={settingsStyles.rowError} testID="host-page-restart-recovery-error">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.sheetActions}>
|
||||||
|
<Button variant="ghost" size="sm" onPress={onClose} disabled={isSaving}>
|
||||||
|
{t("common.actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
testID="host-page-restart-recovery-save"
|
||||||
|
>
|
||||||
|
{t("common.actions.save")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
trailing: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
sheetActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format";
|
import {
|
||||||
import type { UsageBalance, UsageTone } from "./types";
|
formatAmount,
|
||||||
|
formatProviderUsageLabel,
|
||||||
|
formatResetLabel,
|
||||||
|
formatResetTime,
|
||||||
|
} from "./format";
|
||||||
|
import { UsageMeter } from "./meter";
|
||||||
|
import { UsageResetLine } from "./reset-line";
|
||||||
|
import type { UsageBalance } from "./types";
|
||||||
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
||||||
|
|
||||||
interface ResolvedBalance {
|
interface ResolvedBalance {
|
||||||
@@ -41,19 +47,6 @@ function resolveBalance({
|
|||||||
return { amountText: "—", usedPct: null };
|
return { amountText: "—", usedPct: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillToneStyle(tone: UsageTone) {
|
|
||||||
switch (tone) {
|
|
||||||
case "ok":
|
|
||||||
return styles.fillOk;
|
|
||||||
case "warning":
|
|
||||||
return styles.fillWarning;
|
|
||||||
case "danger":
|
|
||||||
return styles.fillDanger;
|
|
||||||
default:
|
|
||||||
return styles.fillDefault;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UsageBalanceBar({
|
export function UsageBalanceBar({
|
||||||
balance,
|
balance,
|
||||||
retained = false,
|
retained = false,
|
||||||
@@ -66,11 +59,7 @@ export function UsageBalanceBar({
|
|||||||
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
|
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
|
||||||
const tone = balance.tone ?? "default";
|
const tone = balance.tone ?? "default";
|
||||||
const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
|
const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
|
||||||
|
const resetTime = formatResetTime(balance.resetsAt, now, t);
|
||||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
|
||||||
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
|
|
||||||
[usedPct, tone],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -78,22 +67,21 @@ export function UsageBalanceBar({
|
|||||||
<Text style={styles.label} numberOfLines={1}>
|
<Text style={styles.label} numberOfLines={1}>
|
||||||
{formatProviderUsageLabel(balance.id, balance.label, t)}
|
{formatProviderUsageLabel(balance.id, balance.label, t)}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.value} testID={`provider-usage-balance-${balance.id}-value`}>
|
<Text
|
||||||
<Text style={styles.amount} numberOfLines={1} ellipsizeMode="tail">
|
style={styles.amount}
|
||||||
{amountText}
|
numberOfLines={1}
|
||||||
</Text>
|
ellipsizeMode="tail"
|
||||||
{resetLabel ? (
|
testID={`provider-usage-balance-${balance.id}-value`}
|
||||||
<Text style={styles.reset} testID={`provider-usage-balance-${balance.id}-reset`}>
|
>
|
||||||
{resetLabel}
|
{amountText}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
{usedPct != null ? (
|
{usedPct != null ? <UsageMeter percent={usedPct} tone={tone} /> : null}
|
||||||
<View style={styles.track}>
|
<UsageResetLine
|
||||||
<View style={fillStyle} />
|
countdown={resetLabel}
|
||||||
</View>
|
exactTime={resetTime}
|
||||||
) : null}
|
testID={`provider-usage-balance-${balance.id}-reset`}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,44 +98,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
flexShrink: 1,
|
|
||||||
alignItems: "flex-end",
|
|
||||||
},
|
|
||||||
amount: {
|
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
reset: {
|
amount: {
|
||||||
color: theme.colors.foregroundMuted,
|
flexShrink: 1,
|
||||||
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
textAlign: "right",
|
|
||||||
},
|
|
||||||
track: {
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
backgroundColor: theme.colors.surface3,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
fill: {
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
},
|
|
||||||
fillDefault: {
|
|
||||||
backgroundColor: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
fillOk: {
|
|
||||||
backgroundColor: theme.colors.statusSuccess,
|
|
||||||
},
|
|
||||||
fillWarning: {
|
|
||||||
backgroundColor: theme.colors.statusWarning,
|
|
||||||
},
|
|
||||||
fillDanger: {
|
|
||||||
backgroundColor: theme.colors.statusDanger,
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
formatPct,
|
formatPct,
|
||||||
formatProviderUsageLabel,
|
formatProviderUsageLabel,
|
||||||
formatResetLabel,
|
formatResetLabel,
|
||||||
|
formatResetTime,
|
||||||
formatRunsOutLabel,
|
formatRunsOutLabel,
|
||||||
} from "./format";
|
} from "./format";
|
||||||
|
|
||||||
@@ -36,8 +37,8 @@ describe("formatAmount", () => {
|
|||||||
const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
|
const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
expect(formatPct(7, "en")).toBe("7%");
|
expect(formatPct(7, "en")).toBe("7%");
|
||||||
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h");
|
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets in 2h");
|
||||||
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h");
|
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h");
|
||||||
|
|
||||||
await i18n.changeLanguage("ja");
|
await i18n.changeLanguage("ja");
|
||||||
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
|
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
|
||||||
@@ -94,6 +95,70 @@ describe("formatAmount", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reset countdown", () => {
|
||||||
|
const MINUTE = 60_000;
|
||||||
|
const HOUR = 60 * MINUTE;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
const countdown = (ms: number) => formatResetLabel(new Date(NOW + ms).toISOString(), false, NOW);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps counting down inside a day instead of sitting on the rounded-down day", () => {
|
||||||
|
expect(countdown(2 * DAY + 23 * HOUR + 10 * MINUTE)).toBe("resets in 2d 23h");
|
||||||
|
expect(countdown(2 * DAY + 22 * HOUR + 10 * MINUTE)).toBe("resets in 2d 22h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says two units, dropping a unit that is zero", () => {
|
||||||
|
expect(countdown(DAY + 30 * MINUTE)).toBe("resets in 1d");
|
||||||
|
expect(countdown(4 * HOUR + 59 * MINUTE)).toBe("resets in 4h 59m");
|
||||||
|
expect(countdown(HOUR)).toBe("resets in 1h");
|
||||||
|
expect(countdown(37 * MINUTE)).toBe("resets in 37m");
|
||||||
|
expect(countdown(59 * 1000)).toBe("resets in 0m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins the at-risk countdown the same way", () => {
|
||||||
|
const iso = new Date(NOW + 3 * HOUR + 5 * MINUTE).toISOString();
|
||||||
|
expect(formatRunsOutLabel(iso, NOW)).toBe("runs out in 3h 5m");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exact reset time", () => {
|
||||||
|
// Local-time dates, so the thresholds hold in any timezone the tests run in.
|
||||||
|
const now = new Date(2026, 9, 29, 12, 0).getTime();
|
||||||
|
const at = (...args: [number, number, number, number, number]) =>
|
||||||
|
formatResetTime(new Date(...args).toISOString(), now)?.replace(/\s/g, " ");
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the time within 24 hours", () => {
|
||||||
|
expect(at(2026, 9, 29, 15, 0)).toMatch(/^(3:00 PM|15:00)$/);
|
||||||
|
expect(at(2026, 9, 30, 11, 59)).toMatch(/^(11:59 AM|11:59)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the weekday within 7 days", () => {
|
||||||
|
expect(at(2026, 9, 30, 12, 0)).toMatch(/^Fri (12:00 PM|12:00)$/);
|
||||||
|
expect(at(2026, 10, 5, 11, 59)).toMatch(/^Thu (11:59 AM|11:59)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the date after 7 days", () => {
|
||||||
|
expect(at(2026, 10, 5, 12, 0)).toMatch(/^Nov 5, (12:00 PM|12:00)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the weekday in the app language", async () => {
|
||||||
|
await i18n.changeLanguage("fr");
|
||||||
|
expect(at(2026, 9, 31, 15, 0)).toMatch(/^sam\.? /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing for a time that has passed", () => {
|
||||||
|
expect(formatResetTime(new Date(now - 1).toISOString(), now)).toBeNull();
|
||||||
|
expect(formatResetTime(null, now)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("retained reset times that have passed do not claim the quota is resetting", () => {
|
it("retained reset times that have passed do not claim the quota is resetting", () => {
|
||||||
const past = "2000-01-01T00:00:00.000Z";
|
const past = "2000-01-01T00:00:00.000Z";
|
||||||
expect(formatResetLabel(past, true)).toBe("Reset time passed");
|
expect(formatResetLabel(past, true)).toBe("Reset time passed");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
|
import { formatTimeOfDay } from "@/utils/time";
|
||||||
import type { UsageDisplayAs } from "./preferences";
|
import type { UsageDisplayAs } from "./preferences";
|
||||||
import type { UsageBalanceUnit } from "./types";
|
import type { UsageBalanceUnit } from "./types";
|
||||||
|
|
||||||
@@ -50,7 +51,17 @@ export function formatDisplayPct(
|
|||||||
: t("providerUsage.values.remaining", { amount: percentage });
|
: t("providerUsage.values.remaining", { amount: percentage });
|
||||||
}
|
}
|
||||||
|
|
||||||
type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number };
|
type DurationUnit = "minutes" | "hours" | "days";
|
||||||
|
interface DurationPart {
|
||||||
|
unit: DurationUnit;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
/** Two units, floored, so a countdown keeps moving: "2d 23h", "4h 59m", "37m". */
|
||||||
|
type RelativeDuration = { kind: "now" } | { kind: "remaining"; parts: DurationPart[] };
|
||||||
|
|
||||||
|
const MINUTE_MS = 60_000;
|
||||||
|
const HOUR_MS = 60 * MINUTE_MS;
|
||||||
|
const DAY_MS = 24 * HOUR_MS;
|
||||||
|
|
||||||
function formatCount(value: number): string {
|
function formatCount(value: number): string {
|
||||||
return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
|
return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
|
||||||
@@ -59,35 +70,34 @@ function formatCount(value: number): string {
|
|||||||
function relativeDuration(iso: string, now: number): RelativeDuration | null {
|
function relativeDuration(iso: string, now: number): RelativeDuration | null {
|
||||||
const diffMs = new Date(iso).getTime() - now;
|
const diffMs = new Date(iso).getTime() - now;
|
||||||
if (!Number.isFinite(diffMs)) return null;
|
if (!Number.isFinite(diffMs)) return null;
|
||||||
if (diffMs <= 0) return { unit: "now" };
|
if (diffMs <= 0) return { kind: "now" };
|
||||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
const days = Math.floor(diffMs / DAY_MS);
|
||||||
const diffHours = Math.floor(diffMinutes / 60);
|
const hours = Math.floor((diffMs % DAY_MS) / HOUR_MS);
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
const minutes = Math.floor((diffMs % HOUR_MS) / MINUTE_MS);
|
||||||
if (diffDays > 0) return { unit: "days", count: diffDays };
|
if (days > 0) return remaining({ unit: "days", count: days }, { unit: "hours", count: hours });
|
||||||
if (diffHours > 0) return { unit: "hours", count: diffHours };
|
if (hours > 0)
|
||||||
return { unit: "minutes", count: diffMinutes };
|
return remaining({ unit: "hours", count: hours }, { unit: "minutes", count: minutes });
|
||||||
|
return remaining({ unit: "minutes", count: minutes });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null {
|
/** The major unit, plus the minor one unless it is zero. */
|
||||||
switch (duration.unit) {
|
function remaining(major: DurationPart, minor?: DurationPart): RelativeDuration {
|
||||||
case "now":
|
return { kind: "remaining", parts: minor && minor.count > 0 ? [major, minor] : [major] };
|
||||||
return null;
|
}
|
||||||
case "days":
|
|
||||||
return t("providerUsage.duration.days", {
|
function formatDurationPart(part: DurationPart, t: TFunction): string {
|
||||||
count: duration.count,
|
return t(`providerUsage.duration.${part.unit}`, {
|
||||||
value: formatCount(duration.count),
|
count: part.count,
|
||||||
});
|
value: formatCount(part.count),
|
||||||
case "hours":
|
});
|
||||||
return t("providerUsage.duration.hours", {
|
}
|
||||||
count: duration.count,
|
|
||||||
value: formatCount(duration.count),
|
function formatRelativeDuration(
|
||||||
});
|
duration: Extract<RelativeDuration, { kind: "remaining" }>,
|
||||||
case "minutes":
|
t: TFunction,
|
||||||
return t("providerUsage.duration.minutes", {
|
): string {
|
||||||
count: duration.count,
|
const [first, second] = duration.parts.map((part) => formatDurationPart(part, t));
|
||||||
value: formatCount(duration.count),
|
return second ? t("providerUsage.duration.pair", { first, second }) : first;
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatResetLabel(
|
export function formatResetLabel(
|
||||||
@@ -99,10 +109,34 @@ export function formatResetLabel(
|
|||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const rel = relativeDuration(iso, now);
|
const rel = relativeDuration(iso, now);
|
||||||
if (!rel) return null;
|
if (!rel) return null;
|
||||||
if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed");
|
if (rel.kind === "now" && retained) return t("providerUsage.timing.resetTimePassed");
|
||||||
if (rel.unit === "now") return t("providerUsage.timing.resettingNow");
|
if (rel.kind === "now") return t("providerUsage.timing.resettingNow");
|
||||||
const duration = formatRelativeDuration(rel, t);
|
return t("providerUsage.timing.resetsIn", { duration: formatRelativeDuration(rel, t) });
|
||||||
return duration ? t("providerUsage.timing.resetsIn", { duration }) : null;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a window resets on the clock: "3:00 PM" within a day, "Fri 3:00 PM" within a week,
|
||||||
|
* "Oct 31, 3:00 PM" after that. Names follow the app language; 12/24h follows the device.
|
||||||
|
* Nothing for a time that has passed, where the countdown already says so.
|
||||||
|
*/
|
||||||
|
export function formatResetTime(
|
||||||
|
iso: string | null | undefined,
|
||||||
|
now = Date.now(),
|
||||||
|
t: TFunction = i18n.t,
|
||||||
|
): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const at = new Date(iso);
|
||||||
|
const diffMs = at.getTime() - now;
|
||||||
|
if (!Number.isFinite(diffMs) || diffMs <= 0) return null;
|
||||||
|
const time = formatTimeOfDay(at);
|
||||||
|
const locale = i18n.resolvedLanguage;
|
||||||
|
if (diffMs < DAY_MS) return time;
|
||||||
|
if (diffMs < 7 * DAY_MS) {
|
||||||
|
const weekday = at.toLocaleDateString(locale, { weekday: "short" });
|
||||||
|
return t("providerUsage.timing.resetsAtWeekday", { weekday, time });
|
||||||
|
}
|
||||||
|
const date = at.toLocaleDateString(locale, { month: "short", day: "numeric" });
|
||||||
|
return t("providerUsage.timing.resetsAtDate", { date, time });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRunsOutLabel(
|
export function formatRunsOutLabel(
|
||||||
@@ -113,9 +147,8 @@ export function formatRunsOutLabel(
|
|||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const rel = relativeDuration(iso, now);
|
const rel = relativeDuration(iso, now);
|
||||||
if (!rel) return null;
|
if (!rel) return null;
|
||||||
if (rel.unit === "now") return t("providerUsage.timing.runsOutNow");
|
if (rel.kind === "now") return t("providerUsage.timing.runsOutNow");
|
||||||
const duration = formatRelativeDuration(rel, t);
|
return t("providerUsage.timing.runsOutIn", { duration: formatRelativeDuration(rel, t) });
|
||||||
return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAgo(
|
export function formatAgo(
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ export function UsageMeter({
|
|||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
track: {
|
track: {
|
||||||
height: 4,
|
height: 6,
|
||||||
borderRadius: 2,
|
borderRadius: 3,
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface3,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reset data of a window or balance: the countdown first, then the exact time. When the line
|
||||||
|
* is too narrow the exact time gives way (truncates) and the countdown stays whole.
|
||||||
|
*/
|
||||||
|
export function UsageResetLine({
|
||||||
|
countdown,
|
||||||
|
exactTime,
|
||||||
|
atRisk = false,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
countdown: string | null;
|
||||||
|
exactTime: string | null;
|
||||||
|
/** The countdown is a runs-out warning rather than a reset. */
|
||||||
|
atRisk?: boolean;
|
||||||
|
testID?: string;
|
||||||
|
}) {
|
||||||
|
if (!countdown) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.line} testID={testID}>
|
||||||
|
<Text style={atRisk ? styles.atRisk : styles.text}>{countdown}</Text>
|
||||||
|
{exactTime ? (
|
||||||
|
<Text style={[styles.text, styles.exactTime]} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{`\u00a0·\u00a0${exactTime}`}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
line: { flexDirection: "row", alignItems: "center" },
|
||||||
|
text: {
|
||||||
|
flexShrink: 0,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
atRisk: {
|
||||||
|
flexShrink: 0,
|
||||||
|
color: theme.colors.statusDanger,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
exactTime: { flexShrink: 1 },
|
||||||
|
}));
|
||||||
@@ -192,12 +192,7 @@ function PinnedUsageLine({
|
|||||||
<Fragment key={window.key}>
|
<Fragment key={window.key}>
|
||||||
{layout === "meters" ? (
|
{layout === "meters" ? (
|
||||||
<View style={[styles.meterSlot, leadStyle]}>
|
<View style={[styles.meterSlot, leadStyle]}>
|
||||||
<UsageMeter
|
<UsageMeter percent={window.percent} tone={window.tone} quiet />
|
||||||
percent={window.percent}
|
|
||||||
tone={window.tone}
|
|
||||||
quiet
|
|
||||||
style={styles.meter}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<Text
|
<Text
|
||||||
@@ -269,10 +264,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minWidth: MIN_METER_WIDTH,
|
minWidth: MIN_METER_WIDTH,
|
||||||
marginRight: METER_GAP,
|
marginRight: METER_GAP,
|
||||||
},
|
},
|
||||||
meter: {
|
|
||||||
height: 6,
|
|
||||||
borderRadius: 3,
|
|
||||||
},
|
|
||||||
percent: {
|
percent: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
import { Pin } from "lucide-react-native";
|
import { Pin } from "lucide-react-native";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
formatDisplayPct,
|
formatDisplayPct,
|
||||||
formatProviderUsageLabel,
|
formatProviderUsageLabel,
|
||||||
formatResetLabel,
|
formatResetLabel,
|
||||||
|
formatResetTime,
|
||||||
formatRunsOutLabel,
|
formatRunsOutLabel,
|
||||||
} from "./format";
|
} from "./format";
|
||||||
import { UsageMeter } from "./meter";
|
import { UsageMeter } from "./meter";
|
||||||
import { displayPercent, usageWindowRowLabel } from "./model";
|
import { displayPercent, usageWindowRowLabel } from "./model";
|
||||||
|
import { UsageResetLine } from "./reset-line";
|
||||||
import type { UsageDisplayAs } from "./preferences";
|
import type { UsageDisplayAs } from "./preferences";
|
||||||
import { windowTone } from "./tone";
|
import { windowTone } from "./tone";
|
||||||
import type { UsageTone, UsageWindow } from "./types";
|
import type { UsageTone, UsageWindow } from "./types";
|
||||||
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
||||||
|
|
||||||
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
|
|
||||||
// so a hover never reads as the selection. Pinned rows do not react to hover.
|
|
||||||
function highlightStyle(pinned: boolean, hovered: boolean) {
|
|
||||||
if (pinned) return styles.highlightPinned;
|
|
||||||
return hovered ? styles.highlightHovered : styles.highlightNone;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UsageWindowBar({
|
export function UsageWindowBar({
|
||||||
window,
|
window,
|
||||||
displayAs,
|
displayAs,
|
||||||
@@ -54,9 +49,12 @@ export function UsageWindowBar({
|
|||||||
window.runsOutAt != null &&
|
window.runsOutAt != null &&
|
||||||
window.shortfallPct != null &&
|
window.shortfallPct != null &&
|
||||||
(!retained || Date.parse(window.runsOutAt) > now);
|
(!retained || Date.parse(window.runsOutAt) > now);
|
||||||
const trailing = isAtRisk
|
const countdown = isAtRisk
|
||||||
? formatRunsOutLabel(window.runsOutAt, now, t)
|
? formatRunsOutLabel(window.runsOutAt, now, t)
|
||||||
: formatResetLabel(window.resetsAt, retained, now, t);
|
: formatResetLabel(window.resetsAt, retained, now, t);
|
||||||
|
// The exact time belongs to the reset; beside a runs-out warning it would read as the moment
|
||||||
|
// the quota runs out.
|
||||||
|
const exactTime = isAtRisk ? null : formatResetTime(window.resetsAt, now, t);
|
||||||
|
|
||||||
const value =
|
const value =
|
||||||
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
|
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
|
||||||
@@ -64,60 +62,57 @@ export function UsageWindowBar({
|
|||||||
const content = {
|
const content = {
|
||||||
label: formatProviderUsageLabel(window.id, window.label, t),
|
label: formatProviderUsageLabel(window.id, window.label, t),
|
||||||
value,
|
value,
|
||||||
trailing,
|
countdown,
|
||||||
|
exactTime,
|
||||||
isAtRisk,
|
isAtRisk,
|
||||||
percent: shownPct ?? 0,
|
percent: shownPct ?? 0,
|
||||||
tone,
|
tone,
|
||||||
pinned,
|
pinned,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Same padding as the pinnable row, so bars line up in both modes.
|
|
||||||
if (!pinnable) {
|
if (!pinnable) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<WindowRowContent {...content} highlight={styles.highlightNone} pinnable={false} />
|
<WindowRowContent {...content} pinnable={false} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same
|
// The whole row pins the window to the sidebar Usage item; the pin glyph is the only sign of it.
|
||||||
// padding so toggling only changes the background.
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onTogglePin}
|
onPress={onTogglePin}
|
||||||
accessibilityRole="checkbox"
|
accessibilityRole="checkbox"
|
||||||
accessibilityLabel={usageWindowRowLabel({ pinLabel, value, trailing })}
|
accessibilityLabel={usageWindowRowLabel({
|
||||||
|
pinLabel,
|
||||||
|
value,
|
||||||
|
trailing: [countdown, exactTime].filter(Boolean).join(" · "),
|
||||||
|
})}
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
aria-checked={pinned}
|
aria-checked={pinned}
|
||||||
style={styles.row}
|
style={styles.row}
|
||||||
testID={pinTestID}
|
testID={pinTestID}
|
||||||
>
|
>
|
||||||
{({ hovered }: { hovered?: boolean }) => (
|
<WindowRowContent {...content} pinnable />
|
||||||
<WindowRowContent
|
|
||||||
{...content}
|
|
||||||
highlight={highlightStyle(pinned, Boolean(hovered))}
|
|
||||||
pinnable
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WindowRowContent({
|
function WindowRowContent({
|
||||||
highlight,
|
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
trailing,
|
countdown,
|
||||||
|
exactTime,
|
||||||
isAtRisk,
|
isAtRisk,
|
||||||
percent,
|
percent,
|
||||||
tone,
|
tone,
|
||||||
pinnable,
|
pinnable,
|
||||||
pinned,
|
pinned,
|
||||||
}: {
|
}: {
|
||||||
highlight: StyleProp<ViewStyle>;
|
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
trailing: string | null | undefined;
|
countdown: string | null;
|
||||||
|
exactTime: string | null;
|
||||||
isAtRisk: boolean;
|
isAtRisk: boolean;
|
||||||
percent: number;
|
percent: number;
|
||||||
tone: UsageTone;
|
tone: UsageTone;
|
||||||
@@ -126,24 +121,17 @@ function WindowRowContent({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={highlight} pointerEvents="none" />
|
<View style={styles.labelRow}>
|
||||||
<View style={styles.contentRow}>
|
<Text style={styles.label} numberOfLines={1}>
|
||||||
<View style={styles.windowContent}>
|
{label}
|
||||||
<View style={styles.labelRow}>
|
</Text>
|
||||||
<Text style={styles.label} numberOfLines={1}>
|
<Text style={styles.value} numberOfLines={1}>
|
||||||
{label}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.value} numberOfLines={1} ellipsizeMode="tail">
|
|
||||||
{value}
|
|
||||||
{trailing ? (
|
|
||||||
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
|
|
||||||
) : null}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<UsageMeter percent={percent} tone={tone} />
|
|
||||||
</View>
|
|
||||||
{pinnable ? <UsagePinGlyph pinned={pinned} /> : null}
|
{pinnable ? <UsagePinGlyph pinned={pinned} /> : null}
|
||||||
</View>
|
</View>
|
||||||
|
<UsageMeter percent={percent} tone={tone} />
|
||||||
|
<UsageResetLine countdown={countdown} exactTime={exactTime} atRisk={isAtRisk} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -153,9 +141,9 @@ const ThemedPin = withUnistyles(Pin);
|
|||||||
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
|
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const iconMapping = useMemo(
|
const iconMapping = useMemo(
|
||||||
() => (theme: { colors: { foregroundMuted: string } }) => ({
|
() => (theme: { colors: { foreground: string; foregroundMuted: string } }) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: pinned ? theme.colors.foreground : theme.colors.foregroundMuted,
|
||||||
fill: pinned ? theme.colors.foregroundMuted : "none",
|
fill: pinned ? theme.colors.foreground : "none",
|
||||||
}),
|
}),
|
||||||
[pinned],
|
[pinned],
|
||||||
);
|
);
|
||||||
@@ -179,59 +167,23 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
row: { gap: 3, paddingVertical: theme.spacing[1.5] },
|
||||||
windowContent: { flex: 1, gap: 3 },
|
|
||||||
pin: { width: 12, alignItems: "center" },
|
pin: { width: 12, alignItems: "center" },
|
||||||
tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm },
|
tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm },
|
||||||
row: {
|
|
||||||
gap: 3,
|
|
||||||
// The highlight bleeds into the card padding so the label and bar stay on the card's rail.
|
|
||||||
marginHorizontal: -theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingVertical: theme.spacing[1.5],
|
|
||||||
// Its own stacking context, so the highlight layer paints above the card and below the text.
|
|
||||||
zIndex: 0,
|
|
||||||
},
|
|
||||||
highlightNone: {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
highlightPinned: {
|
|
||||||
...StyleSheet.absoluteFill,
|
|
||||||
zIndex: -1,
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
// The pinned surface at half strength: a separate layer, so the text keeps full opacity.
|
|
||||||
highlightHovered: {
|
|
||||||
...StyleSheet.absoluteFill,
|
|
||||||
zIndex: -1,
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
opacity: theme.opacity[50],
|
|
||||||
},
|
|
||||||
labelRow: {
|
labelRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
flexShrink: 1,
|
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
reset: {
|
value: {
|
||||||
color: theme.colors.foregroundMuted,
|
flexShrink: 0,
|
||||||
fontWeight: theme.fontWeight.normal,
|
color: theme.colors.foreground,
|
||||||
},
|
fontSize: theme.fontSize.sm,
|
||||||
atRisk: {
|
|
||||||
color: theme.colors.statusDanger,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ function getTimeFormatter(): Intl.DateTimeFormat {
|
|||||||
return cachedTimeFormatter;
|
return cachedTimeFormatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A clock time in the device's 12h/24h preference: "3:00 PM" or "15:00". */
|
||||||
|
export function formatTimeOfDay(date: Date): string {
|
||||||
|
return getTimeFormatter().format(date);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a chat-message timestamp for hover-revealed UI.
|
* Format a chat-message timestamp for hover-revealed UI.
|
||||||
* - Same day: "10:11 PM" or "22:11" depending on user preference
|
* - Same day: "10:11 PM" or "22:11" depending on user preference
|
||||||
@@ -132,7 +137,7 @@ function getTimeFormatter(): Intl.DateTimeFormat {
|
|||||||
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
|
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
|
||||||
*/
|
*/
|
||||||
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
|
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
|
||||||
const time = getTimeFormatter().format(date);
|
const time = formatTimeOfDay(date);
|
||||||
const daysAgo = localCalendarDaysBetween(date, now);
|
const daysAgo = localCalendarDaysBetween(date, now);
|
||||||
|
|
||||||
if (daysAgo === 0) {
|
if (daysAgo === 0) {
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract<ToolCallDetail, { type: "fetch
|
|||||||
return Boolean(detail.url || detail.result || detail.codeText);
|
return Boolean(detail.url || detail.result || detail.codeText);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasMeaningfulSubAgentDetail(
|
|
||||||
detail: Extract<ToolCallDetail, { type: "sub_agent" }>,
|
|
||||||
): boolean {
|
|
||||||
return Boolean(detail.subAgentType || detail.description || detail.log);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasMeaningfulPlainTextDetail(
|
function hasMeaningfulPlainTextDetail(
|
||||||
detail: Extract<ToolCallDetail, { type: "plain_text" }>,
|
detail: Extract<ToolCallDetail, { type: "plain_text" }>,
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -75,9 +69,9 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
|
|||||||
case "fetch":
|
case "fetch":
|
||||||
return hasMeaningfulFetchDetail(detail);
|
return hasMeaningfulFetchDetail(detail);
|
||||||
case "worktree_setup":
|
case "worktree_setup":
|
||||||
return false;
|
// A sub-agent card is a launch marker; the child's activity lives in the sub-agent panel.
|
||||||
case "sub_agent":
|
case "sub_agent":
|
||||||
return hasMeaningfulSubAgentDetail(detail);
|
return false;
|
||||||
case "plain_text":
|
case "plain_text":
|
||||||
return hasMeaningfulPlainTextDetail(detail);
|
return hasMeaningfulPlainTextDetail(detail);
|
||||||
case "plan":
|
case "plan":
|
||||||
@@ -92,7 +86,7 @@ export function isPendingToolCallDetail(params: {
|
|||||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||||
error: unknown;
|
error: unknown;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (params.detail?.type === "worktree_setup") return false;
|
if (params.detail?.type === "worktree_setup" || params.detail?.type === "sub_agent") return false;
|
||||||
const isRunning = params.status === "running" || params.status === "executing";
|
const isRunning = params.status === "running" || params.status === "executing";
|
||||||
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
|
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"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.14.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@getpaseo/server": "0.14.0-beta.1",
|
"@getpaseo/server": "0.14.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0",
|
||||||
"description": "Paseo client SDK package",
|
"description": "Paseo client SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@getpaseo/relay": "0.14.0-beta.1",
|
"@getpaseo/relay": "0.14.0",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import electronPath from "electron";
|
||||||
|
import { build } from "esbuild";
|
||||||
|
|
||||||
|
export async function runAdaptiveThemeRegression(artifactDir) {
|
||||||
|
const { outputFiles } = await build({
|
||||||
|
stdin: {
|
||||||
|
resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
|
||||||
|
contents: `
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
|
||||||
|
StyleSheet.configure({
|
||||||
|
themes: {
|
||||||
|
dark: { foreground: "#fafafa", background: "#181b1a" },
|
||||||
|
light: { foreground: "#1a1a1e", background: "#ffffff" },
|
||||||
|
},
|
||||||
|
settings: { adaptiveThemes: true },
|
||||||
|
});
|
||||||
|
const Message = withUnistyles(({ color }) =>
|
||||||
|
createElement("p", { style: { color } }, "Theme-reactive message"));
|
||||||
|
createRoot(document.getElementById("root")).render(createElement(Message, {
|
||||||
|
style: { margin: 0 },
|
||||||
|
uniProps: theme => ({ color: theme.foreground }),
|
||||||
|
}));
|
||||||
|
window.readThemeForTest = () => UnistylesRuntime.themeName;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
bundle: true,
|
||||||
|
write: false,
|
||||||
|
format: "iife",
|
||||||
|
platform: "browser",
|
||||||
|
alias: { "react-native": "react-native-web" },
|
||||||
|
define: { __DEV__: "false" },
|
||||||
|
resolveExtensions: [".web.js", ".js", ".json"],
|
||||||
|
});
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-adaptive-theme-"));
|
||||||
|
try {
|
||||||
|
await fs.writeFile(path.join(directory, "renderer.js"), outputFiles[0].text);
|
||||||
|
await fs.writeFile(path.join(directory, "main.cjs"), `(${electronRegression.toString()})()`);
|
||||||
|
// A separate process avoids Playwright's focus emulation, which keeps hidden pages visible.
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE;
|
||||||
|
const args = [
|
||||||
|
...(process.platform === "linux" ? ["--no-sandbox"] : []),
|
||||||
|
path.join(directory, "main.cjs"),
|
||||||
|
artifactDir,
|
||||||
|
];
|
||||||
|
const command = process.platform === "linux" ? "xvfb-run" : electronPath;
|
||||||
|
const commandArgs =
|
||||||
|
process.platform === "linux"
|
||||||
|
? ["-a", "--server-args=-screen 0 1280x800x24", electronPath, ...args]
|
||||||
|
: args;
|
||||||
|
await promisify(execFile)(command, commandArgs, {
|
||||||
|
env,
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
return JSON.parse(await fs.readFile(path.join(artifactDir, "adaptive-theme.json"), "utf8"));
|
||||||
|
} finally {
|
||||||
|
await fs.rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function electronRegression() {
|
||||||
|
const { app, BrowserWindow, nativeTheme } = require("electron");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const files = require("node:fs");
|
||||||
|
const paths = require("node:path");
|
||||||
|
const { setTimeout: delay } = require("node:timers/promises");
|
||||||
|
app.setPath("userData", paths.join(__dirname, "profile"));
|
||||||
|
app.on("window-all-closed", () => {});
|
||||||
|
// Electron keeps Linux's --no-sandbox in argv; the artifact directory is always last.
|
||||||
|
const artifactDir = process.argv.at(-1);
|
||||||
|
let window;
|
||||||
|
const observations = [];
|
||||||
|
const colors = {
|
||||||
|
dark: { foreground: "rgb(250, 250, 250)", background: "rgb(24, 27, 26)" },
|
||||||
|
light: { foreground: "rgb(26, 26, 30)", background: "rgb(255, 255, 255)" },
|
||||||
|
};
|
||||||
|
async function read(expression) {
|
||||||
|
return window.webContents.executeJavaScript(expression);
|
||||||
|
}
|
||||||
|
async function expectValue(expression, expected) {
|
||||||
|
let actual;
|
||||||
|
for (let attempt = 0; attempt < 50; attempt++) {
|
||||||
|
actual = await read(expression);
|
||||||
|
if (actual === expected) return;
|
||||||
|
await delay(50);
|
||||||
|
}
|
||||||
|
assert.equal(actual, expected, expression);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await app.whenReady();
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
app.setActivationPolicy("accessory");
|
||||||
|
app.dock.hide();
|
||||||
|
}
|
||||||
|
for (const [from, to] of [
|
||||||
|
["dark", "light"],
|
||||||
|
["light", "dark"],
|
||||||
|
]) {
|
||||||
|
nativeTheme.themeSource = from;
|
||||||
|
window = new BrowserWindow({ width: 600, height: 200, show: false, skipTaskbar: true });
|
||||||
|
await window.loadURL(
|
||||||
|
"data:text/html," +
|
||||||
|
encodeURIComponent(`<!doctype html>
|
||||||
|
<style>body { background: var(--background); } p { font: 20px system-ui; }</style>
|
||||||
|
<div id="root"></div>`),
|
||||||
|
);
|
||||||
|
await read(files.readFileSync(paths.join(__dirname, "renderer.js"), "utf8"));
|
||||||
|
window.showInactive();
|
||||||
|
const foreground =
|
||||||
|
"document.querySelector('p') && getComputedStyle(document.querySelector('p')).color";
|
||||||
|
const background = "getComputedStyle(document.body).backgroundColor";
|
||||||
|
await expectValue(foreground, colors[from].foreground);
|
||||||
|
await expectValue(background, colors[from].background);
|
||||||
|
window.hide();
|
||||||
|
await expectValue("document.visibilityState", "hidden");
|
||||||
|
nativeTheme.themeSource = to;
|
||||||
|
await expectValue(`matchMedia('(prefers-color-scheme: ${to})').matches`, true);
|
||||||
|
// Reading the public runtime while hidden must not consume the pending theme notification.
|
||||||
|
assert.equal(await read("window.readThemeForTest()"), to);
|
||||||
|
window.showInactive();
|
||||||
|
await expectValue("document.visibilityState", "visible");
|
||||||
|
await expectValue(background, colors[to].background);
|
||||||
|
try {
|
||||||
|
await expectValue(foreground, colors[to].foreground);
|
||||||
|
} finally {
|
||||||
|
files.writeFileSync(
|
||||||
|
paths.join(artifactDir, `theme-${from}-to-${to}.png`),
|
||||||
|
(await window.webContents.capturePage()).toPNG(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
observations.push({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
foreground: await read(foreground),
|
||||||
|
background: await read(background),
|
||||||
|
});
|
||||||
|
window.destroy();
|
||||||
|
window = undefined;
|
||||||
|
}
|
||||||
|
files.writeFileSync(
|
||||||
|
paths.join(artifactDir, "adaptive-theme.json"),
|
||||||
|
JSON.stringify(observations, null, 2),
|
||||||
|
);
|
||||||
|
app.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
window?.destroy();
|
||||||
|
app.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
||||||
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
||||||
|
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
|
||||||
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
|
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
|
||||||
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
|
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
|
||||||
|
|
||||||
@@ -1241,6 +1242,7 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adaptiveTheme = await runAdaptiveThemeRegression(artifactDir);
|
||||||
await runAppearanceFontSizeRegression(page);
|
await runAppearanceFontSizeRegression(page);
|
||||||
|
|
||||||
const callerAgentId = await createCallerAgent(daemonPort);
|
const callerAgentId = await createCallerAgent(daemonPort);
|
||||||
@@ -1265,7 +1267,12 @@ async function main() {
|
|||||||
artifactDir,
|
artifactDir,
|
||||||
});
|
});
|
||||||
const pluginLinks = await checkPluginLinks();
|
const pluginLinks = await checkPluginLinks();
|
||||||
writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks });
|
writeJson(path.join(artifactDir, "result.json"), {
|
||||||
|
...report,
|
||||||
|
settingsMemory,
|
||||||
|
pluginLinks,
|
||||||
|
adaptiveTheme,
|
||||||
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`,
|
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`,
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user