Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andGeorge 59e63fbb5e test(muse): exercise child history replay without a live setup turn
Seed a historical child after closing an empty session so the replay
regression reaches resume even when live subagent projection is broken.
Both paths now fail independently with the reported negotiation error.

Co-authored-by: George <90179633+georgepullen@users.noreply.github.com>
2026-10-28 11:41:11 +00:00
George 51e844b540 test(plugins): launch fake muse through the provider command override 2026-10-28 11:38:22 +00:00
George 367a9d908b test(plugins): cover muse subsession negotiation through PluginRuntime 2026-10-28 11:38:22 +00:00
George eef309ec06 docs(plugins): require session.subsession negotiation before opening a child 2026-10-28 11:38:22 +00:00
George d75f60ebcc test(muse-provider): reach the subsession gate with a child-bearing fixture 2026-10-28 11:38:22 +00:00
George 6a4c05ce82 fix(muse-provider): negotiate session.subsession and gate child projection 2026-10-28 11:38:21 +00:00
189 changed files with 1855 additions and 6260 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: CI
on:
push:
branches: [main]
# Every base branch, so stacked PRs and PRs into any base get the same checks.
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
pull_request:
merge_group:
workflow_dispatch:
+8 -19
View File
@@ -1,17 +1,6 @@
# Changelog
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
## 0.14.0-beta.1 - 2026-10-27
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
@@ -21,8 +10,9 @@ Voice settings now let you configure speech providers and download local models
### 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))
- 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))
- 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))
- 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 a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
@@ -32,9 +22,10 @@ Voice settings now let you configure speech providers and download local models
### 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))
- 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))
- 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))
@@ -43,6 +34,7 @@ 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 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))
- 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
@@ -59,20 +51,17 @@ 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 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 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 inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- 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 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))
### Notes
- 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
- 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))
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
## 0.13.2 - 2026-10-26
+7 -19
View File
@@ -1,17 +1,6 @@
# 更新日志
尚未发布。以下变更已合入 `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
## 0.14.0-beta.1 - 2026-10-27
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
@@ -22,7 +11,7 @@
### 新增
- 插件现在可以为特定 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), [#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))
- 新增已完成对话轮次的折叠和展开功能 ([#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))
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- 语音设置新增可下载的本地语音模型,支持使用 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))
@@ -32,9 +21,10 @@
### 改进
- 新增采用 `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 窗格中新增返回父 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))
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
@@ -43,6 +33,7 @@
- 减少打开和分页加载大型 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))
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#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))
### 修复
@@ -59,20 +50,17 @@
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- 修复强调、链接和标题中行内代码的字号问题 ([#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))
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#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))
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#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))
### 说明
- 语音模式默认关闭,可在语音设置中启用
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
- 降级至 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 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
## 0.13.2 - 2026-10-26
+4 -3
View File
@@ -117,9 +117,10 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
## Release branches
Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each
release builds in a short-lived release workspace from `origin/main` or a release tag; see
[release base and source](docs/release.md#release-base-and-source).
When the user says "this goes to next", create or
retarget the PR to `next` and preserve that destination through delivery. Follow
[release branch discipline](docs/release.md#release-branch-discipline) for creating
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
## Critical rules
-4
View File
@@ -95,8 +95,4 @@ 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.
## 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).
+2 -19
View File
@@ -9,7 +9,7 @@
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -23,7 +23,6 @@ const root = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(root, "../..");
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
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
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
@@ -117,7 +116,6 @@ const STORES = {
},
google: {
device: "iphone",
androidScreenshots: "phoneScreenshots",
width: 1440,
height: 2560,
textTop: 150,
@@ -136,7 +134,6 @@ const STORES = {
},
"google-tablet": {
device: "ipad",
androidScreenshots: "tenInchScreenshots",
width: 1800,
height: 3200,
textTop: 200,
@@ -355,18 +352,6 @@ async function expectLines(view, selector, expected, label) {
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) {
const tile = 440;
const gap = 40;
@@ -402,9 +387,7 @@ try {
const marks = await loadAgentMarks(work);
const rows = [];
for (const [name, store] of Object.entries(STORES)) {
const files = await renderStore(browser, name, store, font, marks, work);
if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files);
rows.push(files);
rows.push(await renderStore(browser, name, store, font, marks, work));
}
await renderPreview(rows);
} finally {
+2 -24
View File
@@ -12,26 +12,6 @@ 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.
## 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
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
@@ -159,10 +139,8 @@ caller, child, and host into one watch.
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
the child's run has settled; the parent decides whether its task is complete. A child chosen for restart
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
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
reporting after detachment.
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
+1 -4
View File
@@ -215,8 +215,7 @@ fastlane/metadata/android/
│ ├── images/
│ │ ├── icon.png (512x512)
│ │ ├── featureGraphic.png (1024x500)
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
│ └── changelogs/ (generated — see below)
├── ja/
└── zh-CN/
@@ -224,8 +223,6 @@ fastlane/metadata/android/
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.
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
View File
@@ -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.
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.
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.
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.
+7 -9
View File
@@ -200,15 +200,13 @@ its own.
## Gotchas
- **Web enters without Reanimated.** For a custom keyframe, Reanimated's web path snapshots the
surface's rect when the animation ends and writes it back as inline top/left/width/height 750ms
after the start. Whatever moved or resized the surface in between was undone: content that
grew once its data arrived left the menu clipped, or hanging over its anchor, until it moved
again. `AnchoredSurface` plays the same keyframes through the Web Animations API on web, which
leaves no inline style; native keeps the Reanimated `Keyframe`.
- **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.
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
identity changes — a pushed page taller than the one it replaced is clipped without that.
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
entering animation never ends at the off-screen measuring position; on a slow machine that
write moved the open menu back off-screen.
- **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.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
-6
View File
@@ -447,12 +447,6 @@ owns payloads, ordering, failure behavior, and examples. `agent.create.before` e
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.
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
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.
+74 -147
View File
@@ -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.
**Preparation** (reversible — agent does this):
**Preparation** (local, reversible — agent does this):
- format, lint, typecheck all 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
- resolve the release source to one commit and confirm that commit's existing CI is green
- 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
- draft the changelog, show it to the user, wait for review
@@ -17,10 +17,9 @@ A release has exactly two steps. The agent does the first, the user authorizes t
**Go-ahead** (user says "go ahead"):
- commit the approved release inputs locally on the release branch
- run the release, which publishes npm and pushes the release branch and tag
- commit the approved release inputs locally
- run the release, which publishes npm and pushes the prepared branch and tag
- create the release heartbeat immediately and babysit it to completion
- [reconcile `main`](#reconcile-main) with what shipped
Rules that apply to both steps:
@@ -28,128 +27,56 @@ 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.
- 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.
- 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.
## Main stays open
## Release source and CI
Merge normal PRs into `main` at any time, including while a beta soaks or a release is being
finalized. Each release builds in a short-lived release workspace, and its published tag records
the shipped source.
The default release source is `origin/main`. Fetch `origin`, then record the
resolved commit. The default release checkout is a clean local `main` whose
`HEAD` equals `origin/main`.
## Choosing a path
An explicit user instruction can select another ref, such as a hotfix commit or
tag. Resolve that ref once and apply every source, diff, and CI check to that
commit instead of `origin/main`.
The user decides when the changes on `main` warrant a release and how much risk it takes:
Before making release-preparation commits, confirm the existing CI run for the
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.
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`.
## Release branch discipline
A beta follows these defaults, which the user can explicitly override:
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
release. This applies to both beta and stable releases.
- Soak a beta at least 24 hours before promotion. Soak longer for risky or breaking changes, or
while feedback is open. Changes added in a later beta get their own soak; earlier soak time does
not count for them.
- A follow-up beta carries regressions, fixes, and polish for what that beta delivers. Unrelated
work on `main` and pre-existing bugs wait for a later release unless the user explicitly selects
them.
- Promotion ships the tested beta's product content unchanged and does not pull from `main`.
Adding a product change needs the user's explicit selection and a new beta, which soaks 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.
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
- "This goes to next" means create the PR against `next` or retarget an existing
PR, and keep that destination through delivery.
- Keep `next` current by merging `origin/main` into it as release fixes land.
Avoid rebasing this shared branch because agents and open PRs depend on its history.
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
Pass CI and merge without squashing away the individual PR commits needed for
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
npm dist-tags are independent pointers:
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
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.
- 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.
### Hotfix from a release tag
## Release base and source
The release base is the tag or commit a release workspace starts from. The release source is the
base plus any approved cherry-picks, and every source, diff, and CI check uses it.
| 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`.
If `main` contains changes you do not want to release, branch from the affected
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
or beta version. Ensure the fixes and changelog also reach `main` and any active
`next`, preserving newer development and version changes there. This is a
short-lived hotfix branch, not another maintained release track.
## ACP catalog updates
@@ -164,6 +91,21 @@ ACP catalog work enters a release through an explicit user request:
The release authorization covers the requested ACP commit. It ships in the same
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
Every fresh release starts by classifying the full diff from the previous
@@ -191,7 +133,7 @@ as described in **Fixing a failed release build**.
Before running any stable release command:
- Make sure the release source passed CI, the approved release inputs are committed locally on the release branch, and the working tree is clean.
- 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.
- **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.
@@ -203,9 +145,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`.
After the stable release succeeds, check `npm view @getpaseo/cli dist-tags`. When the new stable
is newer than `beta`, move `beta` to it for every published package. This changes dist-tags only;
do not republish the packages:
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
@@ -214,8 +156,8 @@ for package in highlight relay protocol client plugin server cli; do
done
```
Verify `latest` resolves to `PASEO_VERSION`, and `beta` resolves to `PASEO_VERSION` or the
active newer beta, before considering the stable release complete.
Verify both npm tags now resolve to `PASEO_VERSION` 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`.
@@ -233,7 +175,7 @@ npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version when it is newer than beta (see above).
# Then move npm's beta dist-tag to this stable version using the command above.
```
## Beta flow
@@ -363,8 +305,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:
- **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 uploads the store screenshots and submits that same build for App Store review.
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
- **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.
- **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.
@@ -375,17 +317,6 @@ 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.
### 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
Use the EAS CLI from `packages/app/`:
@@ -428,7 +359,6 @@ For the `Release Mobile` EAS workflow, these jobs must pass:
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
- `build_android` — Android store binary built
- `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.
@@ -547,7 +477,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
```
This ensures the checkout ref matches the code you want built, with the fix included.
This ensures the checkout ref matches the actual code on `main` with the fix included.
- `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
@@ -690,7 +620,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).
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.
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.
## Changelog ordering
@@ -722,11 +652,11 @@ Every entry covers `previous stable release → release source`: first beta, lat
### Beta release
- [ ] 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
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] 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 changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
@@ -737,24 +667,22 @@ 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 `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
- [ ] 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
### Stable release (or promotion)
- [ ] 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 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
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion 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
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] `latest` resolves to the new stable, and `beta` resolves to it too unless an active newer beta holds `beta`
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] 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
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
@@ -768,5 +696,4 @@ 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 Android `build_android` completes for the same tag
- [ ] 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
-5
View File
@@ -10,11 +10,6 @@ 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
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:
> A continuously subscribed client applies every committed row in order. Opening or resuming an
@@ -1,10 +0,0 @@
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,10 +0,0 @@
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,10 +0,0 @@
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,10 +0,0 @@
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 @@
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
../../../../../../packages/website/public/phone-1.png
@@ -1 +1 @@
../../../../../../assets/store-screenshots/output/google/02-chat.png
../../../../../../packages/website/public/phone-2.png
@@ -1 +1 @@
../../../../../../assets/store-screenshots/output/google/03-plugins.png
../../../../../../packages/website/public/phone-3.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google/04-review.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google/05-terminal.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
+1 -1
View File
@@ -1 +1 @@
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37590,7 +37590,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39793,12 +39793,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/server": "0.14.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/server": "0.14.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -40045,10 +40045,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -40060,7 +40060,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40304,7 +40304,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40350,7 +40350,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -40379,15 +40379,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40397,8 +40397,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40420,7 +40420,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -40447,7 +40447,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -40668,16 +40668,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/highlight": "0.14.0",
"@getpaseo/plugin": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/highlight": "0.14.0-beta.1",
"@getpaseo/plugin": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41598,7 +41598,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43208,7 +43208,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -42,20 +42,6 @@ jobs:
build_id: ${{ needs.build_android.outputs.build_id }}
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:
name: Submit iOS for App Store review
needs: [build_ios, distribute_ios]
@@ -1,27 +0,0 @@
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
@@ -0,0 +1,69 @@
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,26 +204,19 @@ test.describe("Projects settings", () => {
}) => {
const repo = await createTempGitRepo("projects-settings-empty-");
const client = await connectSeedClient();
let projectViewKey: string | null = null;
let projectId: string | null = null;
try {
await gotoAppShell(page);
projectViewKey = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectViewKey);
projectId = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectId);
await expectProjectSettingsFormVisible(page);
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
} finally {
if (projectViewKey) {
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);
if (projectId) {
await client.removeProject(projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
@@ -30,10 +30,6 @@ import {
addDirectHostFromSettings,
expectHostOnlineWithoutError,
expectIdleAgentRelease,
editRestartRecoveryPrompt,
expectRestartRecoveryEnabled,
expectRestartRecoveryPrompt,
setRestartRecoveryEnabled,
setIdleAgentRelease,
} from "../support/helpers/settings";
@@ -256,20 +252,3 @@ test.describe("Settings host page", () => {
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,8 +415,19 @@ test.describe("Settings — compact master-detail", () => {
const workspace = await withWorkspace({ prefix: "host-picker-settings-back-" });
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), workspace.workspaceId);
await openWorkspace(page, workspace);
const worktreeSetupCallout = page.locator('[data-testid^="worktree-setup-callout-"]');
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();
// 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.getByRole("button", { name: /Open .* settings/ }).click();
@@ -53,18 +53,11 @@ async function quickDragFirstRowAfterSecond(
await page.mouse.move(source.x, source.y + 7);
await expect(trailingScrim).toHaveCount(0);
await page.mouse.move(target.x, target.y, { steps: 4 });
await finishSidebarDrag(page);
await page.mouse.up();
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> {
const before = await rowTestIds(rows);
const source = await visibleBoundingBox(rows.nth(0));
@@ -73,7 +66,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.down();
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
await finishSidebarDrag(page);
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual(before);
}
@@ -1,134 +0,0 @@
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();
}
});
});
}
+4 -4
View File
@@ -717,15 +717,15 @@ test("open usage clocks advance without refreshing or reopening", async ({ page
await openCompactUsageWithClock(page, now);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible();
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
// 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.
await advanceUsageClock(page, 120_000);
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible();
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
/^resets in (28|29)m\s·\s/,
/^resets (28|29)m$/,
);
} finally {
await login.cleanup();
@@ -0,0 +1,35 @@
# 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`.
@@ -0,0 +1,26 @@
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,41 +560,3 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
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 -64
View File
@@ -1,47 +1,7 @@
require "fileutils"
require "tmpdir"
default_platform(:ios)
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
desc "Submit the latest TestFlight build to App Store Review"
lane :submit_review do
@@ -127,8 +87,7 @@ platform :ios do
},
force: true,
skip_binary_upload: true,
screenshots_path: stage_app_store_screenshots,
overwrite_screenshots: true,
skip_screenshots: true,
precheck_include_in_app_purchases: false,
submission_information: {
add_id_info_uses_idfa: false,
@@ -137,25 +96,3 @@ platform :ios do
)
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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
+2
View File
@@ -84,6 +84,7 @@ import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-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 { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
@@ -486,6 +487,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<DownloadToast />
<RosettaCalloutSource />
<UpdateCalloutSource />
<DaemonOutdatedCalloutSource />
<LegacyAgentSkillsMigration />
<WorktreeSetupCalloutSource />
<CommandCenterRootActions />
@@ -0,0 +1,182 @@
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;
}
+4 -1
View File
@@ -40,6 +40,7 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
@@ -605,6 +606,8 @@ function MobileSidebar({
/>
)}
<SidebarCalloutSlot nativeOnly />
<SidebarFooter
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
@@ -784,7 +787,7 @@ function DesktopSidebar({
/>
)}
<SidebarCalloutSlot />
<SidebarCalloutSlot nativeOnly={isNative} />
<SidebarFooter
handleOpenProject={handleOpenProject}
+14 -21
View File
@@ -49,7 +49,7 @@ export function ResizeHandle({
const { theme } = useUnistyles();
const finePointer = useHasFinePointer();
const pointerStatesRef = useRef(new Map<number, PointerState>());
const touchStateRef = useRef<PointerState | null>(null);
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
@@ -151,30 +151,23 @@ export function ResizeHandle({
const touchGesture = useMemo(() => {
const gesture = Gesture.Pan()
.runOnJS(true)
.onBegin((event) => {
setDragging(true);
touchStateRef.current = {
containerSize,
pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY,
drag: startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
}),
};
.onBegin(() => setDragging(true))
.onStart(() => {
touchDragRef.current = startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
});
})
.onUpdate((event) => {
const state = touchStateRef.current;
if (!state || state.containerSize <= 0) return;
// 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);
if (containerSize <= 0) return;
const translation = direction === "horizontal" ? event.translationX : event.translationY;
touchDragRef.current?.move(translation / containerSize);
})
.onEnd(() => touchStateRef.current?.drag.finish())
.onEnd(() => touchDragRef.current?.finish())
.onFinalize(() => {
touchStateRef.current = null;
touchDragRef.current = null;
setDragging(false);
});
@@ -0,0 +1,195 @@
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");
}
});
});
@@ -0,0 +1,147 @@
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",
};
}
-1
View File
@@ -1 +0,0 @@
export * from "./sidebar-callout-slot.native";
@@ -1,3 +0,0 @@
export function SidebarCalloutSlot() {
return null;
}
@@ -91,7 +91,7 @@ describe("SidebarCalloutSlot", () => {
root?.render(
<SidebarCalloutProvider>
<RegisterCallout />
<SidebarCalloutSlot />
<SidebarCalloutSlot nativeOnly={false} />
</SidebarCalloutProvider>,
);
await Promise.resolve();
@@ -105,7 +105,7 @@ describe("SidebarCalloutSlot", () => {
await act(async () => {
root?.render(
<SidebarCalloutProvider>
<SidebarCalloutSlot />
<SidebarCalloutSlot nativeOnly={false} />
</SidebarCalloutProvider>,
);
await Promise.resolve();
@@ -2,10 +2,14 @@ import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
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 (
<View style={styles.slot} collapsable={false}>
<SidebarCalloutViewport />
<SidebarCalloutViewport nativeOnly={nativeOnly} />
</View>
);
}
@@ -48,7 +48,6 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import {
collectAllPanes,
createDefaultLayout,
selectExplorerSidebarPaneId,
useWorkspaceLayoutStore,
type SplitNode,
type SplitPane,
@@ -184,8 +183,8 @@ export function SplitContainer({
} | null>(null);
const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
selectExplorerSidebarPaneId(state, workspaceKey),
const explorerSidebarPaneId = useWorkspaceLayoutStore(
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
);
const mainRoot = useMemo(
@@ -1,124 +0,0 @@
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);
});
});
@@ -2,7 +2,6 @@ import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useState,
type ReactElement,
@@ -48,41 +47,56 @@ const contentEntering = new Keyframe({
100: { opacity: 1, transform: [{ scale: 1 }] },
}).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({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
function releaseFixedMenuHeight(surfaceNativeID: string): void {
if (!isWeb) return;
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
}
/**
* On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's
* web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe
* 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
* rect and stayed there, clipped or hanging past its anchor. A Web Animation leaves no inline style.
* Reanimated's web entering animation leaves an inline height snapshot on the measured surface.
* Once the menu is open, height must return to content-sized so rows can grow in place — and so
* a submenu page taller than the page it replaced is not clipped to the old height.
*
* `revision` is what makes that second case work: bump it whenever the rendered content changes
* identity (a page push, for instance) and the snapshot is released again.
*/
function useWebContentEntering({
placed,
function useReleaseFixedMenuHeight({
contentSize,
enabled,
surfaceNativeID,
revision,
}: {
placed: boolean;
contentSize: Size | null;
enabled: boolean;
surfaceNativeID: string;
revision?: string | number;
}): void {
useLayoutEffect(() => {
if (!isWeb || !placed || typeof document === "undefined") return undefined;
const animation = document
.getElementById(surfaceNativeID)
?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS);
return () => animation?.cancel();
}, [placed, surfaceNativeID]);
useEffect(() => {
if (!enabled) return undefined;
const release = () => {
releaseFixedMenuHeight(surfaceNativeID);
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => releaseFixedMenuHeight(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]);
}
/**
@@ -210,6 +224,8 @@ export interface AnchoredSurfaceProps {
fullWidth?: boolean;
horizontalPadding?: number;
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. */
backdrop?: boolean;
/**
@@ -243,6 +259,7 @@ export function AnchoredSurface({
fullWidth = false,
horizontalPadding = 16,
scrollable = false,
revision,
backdrop = true,
onPointerEnter,
onPointerLeave,
@@ -252,25 +269,33 @@ export function AnchoredSurface({
}: AnchoredSurfaceProps): ReactElement | null {
const { t } = useTranslation();
const surfaceNativeID = useId();
const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } =
useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
});
useReleaseFixedMenuHeight({
contentSize,
enabled: open,
surfaceNativeID,
revision,
});
// The surface is placed once both measurements land. Until then it sits off-screen so it can
// be measured, and it must not animate: an entering animation that ran there would be over
// before anyone saw the menu. Remounting on placement starts the native animation from the
// final position; web starts its own animation on placement.
// be measured, and it must not animate: Reanimated's web entering path snapshots the element's
// rect when the animation ends and writes that rect back as inline top/left when it cleans the
// keyframe up 750ms later. If the animation ends before placement, the snapshot is the
// off-screen rect and the menu jumps back off-screen. Remounting on placement starts the
// animation from the final position.
const placed = position !== null;
useWebContentEntering({ placed, surfaceNativeID });
useEffect(() => {
if (!isWeb || !open || !placed || typeof document === "undefined") return undefined;
const frame = requestAnimationFrame(() => {
@@ -358,7 +383,7 @@ export function AnchoredSurface({
dataSet={surfaceDataSet}
style={styles.content}
frameStyle={frameStyle}
entering={placed ? nativeContentEntering : undefined}
entering={placed ? contentEntering : undefined}
exiting={
!placed || isWeb || !onExited
? undefined
@@ -344,6 +344,7 @@ function MenuFlyout({
maxHeight={maxHeight}
scrollable={scrollable}
backdrop={false}
revision={page.id}
onPointerEnter={cancelHoverClose}
onPointerLeave={handleHoverOut}
testID={testID}
@@ -21,6 +21,7 @@ import {
showSidebarCallout,
type SidebarCalloutEntry,
type SidebarCalloutOptions,
type SidebarCalloutSelectionState,
type SidebarCalloutState,
unregisterSidebarCallout,
} from "./sidebar-callout-state";
@@ -37,7 +38,7 @@ export interface SidebarCalloutsApi {
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutSelectionState | null>(null);
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
void AsyncStorage.setItem(
@@ -113,11 +114,10 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
});
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
return (
<SidebarCalloutApiContext.Provider value={api}>
<SidebarCalloutStateContext.Provider value={activeCallout}>
<SidebarCalloutStateContext.Provider value={state}>
{children}
</SidebarCalloutStateContext.Provider>
</SidebarCalloutApiContext.Provider>
@@ -132,12 +132,20 @@ export function useSidebarCallouts(): SidebarCalloutsApi {
return api;
}
export function useActiveSidebarCallout(): SidebarCalloutEntry | null {
return useContext(SidebarCalloutStateContext);
export function useActiveSidebarCallout(
options: { nativeOnly?: boolean } = {},
): SidebarCalloutEntry | null {
const state = useContext(SidebarCalloutStateContext);
const nativeOnly = options.nativeOnly === true;
return useMemo(
() => (state ? selectActiveSidebarCallout(state, { nativeOnly }) : null),
[nativeOnly, state],
);
}
export function SidebarCalloutViewport() {
const activeCallout = useActiveSidebarCallout();
/** The active callout. `nativeOnly` limits it to callouts marked `native`, for native apps and compact sidebars. */
export function SidebarCalloutViewport({ nativeOnly = false }: { nativeOnly?: boolean }) {
const activeCallout = useActiveSidebarCallout({ nativeOnly });
const api = useSidebarCallouts();
if (!activeCallout) {
return null;
@@ -31,6 +31,30 @@ describe("sidebar callout state", () => {
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", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
@@ -14,6 +14,8 @@ export interface SidebarCalloutOptions {
priority?: number;
onDismiss?: () => void;
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 {
@@ -129,10 +131,19 @@ export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCallout
return { ...state, callouts: [] };
}
export type SidebarCalloutSelectionState = Pick<
SidebarCalloutState,
"callouts" | "dismissedKeys" | "dismissalStorageLoaded"
>;
export function selectActiveSidebarCallout(
state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
state: SidebarCalloutSelectionState,
options: { nativeOnly?: boolean } = {},
): SidebarCalloutEntry | null {
const visibleCallouts = state.callouts.filter((entry) => {
if (options.nativeOnly && entry.native !== true) {
return false;
}
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
+12 -11
View File
@@ -2058,6 +2058,18 @@ export const ar: TranslationResources = {
sidebarCallout: {
dismiss: "رفض",
},
daemonOutdated: {
title: "الخدمة قديمة",
versions: "يشغّل {{host}} الخدمة {{daemonVersion}}. إصدار هذا التطبيق {{appVersion}}.",
terminalsStayOpen: "يعيد التحديث تشغيل الخدمة. تبقى الطرفيات مفتوحة.",
terminalsClose: "يعيد التحديث تشغيل الخدمة ويغلق الطرفيات المفتوحة.",
desktopUpdateTerminals:
"يُبقي تحديث Paseo Desktop الطرفيات مفتوحة على macOS وLinux، ويغلقها على Windows.",
desktopManaged: "يدير Paseo Desktop هذه الخدمة. حدّث Paseo على ذلك الحاسوب.",
selfUpdateUnsupported: "لا يمكن لهذه الخدمة تحديث نفسها. حدّث Paseo على ذلك الحاسوب.",
update: "تحديث",
updateFailedTitle: "فشل تحديث الخدمة",
},
contextWindow: {
noData: "لا توجد بيانات للسياق",
accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق",
@@ -2157,7 +2169,6 @@ export const ar: TranslationResources = {
minutes_few: "{{value}} دقائق",
minutes_many: "{{value}} دقيقة",
minutes_other: "{{value}} دقيقة",
pair: "{{first}} و{{second}}",
},
timing: {
lastUpdated: "آخر تحديث {{relativeTime}}",
@@ -2165,8 +2176,6 @@ export const ar: TranslationResources = {
resetTimePassed: "مضى وقت إعادة التعيين",
resettingNow: "تتم إعادة التعيين الآن",
resetsIn: "تتم إعادة التعيين خلال {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}، {{time}}",
runsOutNow: "ينفد الآن",
runsOutIn: "ينفد خلال {{duration}}",
justNow: "الآن",
@@ -2781,14 +2790,6 @@ export const ar: TranslationResources = {
},
agents: {
unavailable: "Connect to this host to manage agents",
resumeOnStartup: {
title: "استئناف الوكلاء بعد إعادة التشغيل",
info: "إعادة فتح الوكلاء الذين كانوا يعملون عند توقف المضيف وإرسال طلب الاستئناف. ينطبق ذلك بعد إعادة التشغيل العادية والتعطّل.",
hint: "متابعة العمل المنقطع تلقائيًا.",
prompt: "طلب الاستئناف",
edit: "تحرير",
editAccessibilityLabel: "تعديل الطلب",
},
idleRuntimeCleanup: {
title: "تحرير الوكلاء الخاملين",
hint: "تحرير الذاكرة بعد ساعة من الخمول.",
+14 -13
View File
@@ -2083,6 +2083,18 @@ export const en = {
sidebarCallout: {
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: {
noData: "No context data",
accessibilityNoData: "Context window: No context data",
@@ -2164,18 +2176,15 @@ export const en = {
days: "{{value}}d",
hours: "{{value}}h",
minutes: "{{value}}m",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Last updated {{relativeTime}}",
noSuccessfulUpdate: "No successful update",
resetTimePassed: "Reset time passed",
resettingNow: "resetting now",
resetsIn: "resets in {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
resetsIn: "resets {{duration}}",
runsOutNow: "runs out now",
runsOutIn: "runs out in {{duration}}",
runsOutIn: "runs out {{duration}}",
justNow: "just now",
daysAgo: "{{value}}d ago",
hoursAgo: "{{value}}h ago",
@@ -2873,14 +2882,6 @@ export const en = {
},
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: {
title: "Release idle agents",
hint: "Free up memory after an hour of inactivity.",
+13 -11
View File
@@ -2106,6 +2106,19 @@ export const es: TranslationResources = {
sidebarCallout: {
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: {
noData: "No hay datos de contexto",
accessibilityNoData: "Ventana de contexto: No hay datos de contexto",
@@ -2187,7 +2200,6 @@ export const es: TranslationResources = {
days: "{{value}} d",
hours: "{{value}} h",
minutes: "{{value}} min",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Última actualización {{relativeTime}}",
@@ -2195,8 +2207,6 @@ export const es: TranslationResources = {
resetTimePassed: "Pasó la hora de restablecimiento",
resettingNow: "se restablece ahora",
resetsIn: "se restablece en {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "se agota ahora",
runsOutIn: "se agota en {{duration}}",
justNow: "ahora mismo",
@@ -2803,14 +2813,6 @@ export const es: TranslationResources = {
},
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: {
title: "Liberar agentes inactivos",
hint: "Libera memoria tras una hora de inactividad.",
+13 -11
View File
@@ -2105,6 +2105,19 @@ export const fr: TranslationResources = {
sidebarCallout: {
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: {
noData: "Aucune donnée de contexte",
accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte",
@@ -2187,7 +2200,6 @@ export const fr: TranslationResources = {
days: "{{value}} j",
hours: "{{value}} h",
minutes: "{{value}} min",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Dernière mise à jour {{relativeTime}}",
@@ -2195,8 +2207,6 @@ export const fr: TranslationResources = {
resetTimePassed: "Heure de réinitialisation dépassée",
resettingNow: "réinitialisation en cours",
resetsIn: "se réinitialise dans {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "s’épuise maintenant",
runsOutIn: "s’épuise dans {{duration}}",
justNow: "à l’instant",
@@ -2806,14 +2816,6 @@ export const fr: TranslationResources = {
},
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: {
title: "Libérer les agents inactifs",
hint: "Libère de la mémoire après une heure d’inactivité.",
+14 -11
View File
@@ -2078,6 +2078,20 @@ export const ja: TranslationResources = {
sidebarCallout: {
dismiss: "閉じる",
},
daemonOutdated: {
title: "デーモンが古くなっています",
versions: "{{host}}のデーモンは{{daemonVersion}}です。このアプリは{{appVersion}}です。",
terminalsStayOpen: "更新するとデーモンが再起動します。ターミナルは開いたままです。",
terminalsClose: "更新するとデーモンが再起動し、開いているターミナルは閉じられます。",
desktopUpdateTerminals:
"Paseo Desktop を更新すると、macOS と Linux ではターミナルは開いたままですが、Windows では閉じられます。",
desktopManaged:
"このデーモンはPaseo Desktopによって管理されています。そのコンピューターでPaseoを更新してください。",
selfUpdateUnsupported:
"このデーモンは自身を更新できません。そのコンピューターでPaseoを更新してください。",
update: "更新",
updateFailedTitle: "デーモンを更新できませんでした",
},
contextWindow: {
noData: "コンテキストデータがありません",
accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません",
@@ -2160,7 +2174,6 @@ export const ja: TranslationResources = {
days: "{{value}}日",
hours: "{{value}}時間",
minutes: "{{value}}分",
pair: "{{first}}{{second}}",
},
timing: {
lastUpdated: "最終更新: {{relativeTime}}",
@@ -2168,8 +2181,6 @@ export const ja: TranslationResources = {
resetTimePassed: "リセット時刻を過ぎています",
resettingNow: "まもなくリセット",
resetsIn: "{{duration}}後にリセット",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}} {{time}}",
runsOutNow: "まもなく上限に到達",
runsOutIn: "{{duration}}後に上限に到達",
justNow: "たった今",
@@ -2770,14 +2781,6 @@ export const ja: TranslationResources = {
},
agents: {
unavailable: "エージェントを管理するにはこのホストに接続してください",
resumeOnStartup: {
title: "再起動後にエージェントを再開",
info: "ホストの停止時に作業中だったエージェントを開き直し、再開メッセージを送信します。通常の再起動とクラッシュの両方に適用されます。",
hint: "中断された作業を自動的に続行します。",
prompt: "再開メッセージ",
edit: "編集",
editAccessibilityLabel: "メッセージを編集",
},
idleRuntimeCleanup: {
title: "アイドル状態のエージェントを解放",
hint: "1 時間のアイドル状態の後にメモリを解放します。",
+14 -11
View File
@@ -2068,6 +2068,20 @@ export const ko: TranslationResources = {
sidebarCallout: {
dismiss: "닫기",
},
daemonOutdated: {
title: "데몬이 오래되었습니다",
versions: "{{host}}의 데몬은 {{daemonVersion}}입니다. 이 앱은 {{appVersion}}입니다.",
terminalsStayOpen: "업데이트하면 데몬이 다시 시작됩니다. 터미널은 열린 상태로 유지됩니다.",
terminalsClose: "업데이트하면 데몬이 다시 시작되고 열려 있는 터미널이 닫힙니다.",
desktopUpdateTerminals:
"Paseo Desktop을 업데이트하면 macOS와 Linux에서는 터미널이 열린 상태로 유지되고, Windows에서는 닫힙니다.",
desktopManaged:
"이 데몬은 Paseo Desktop에서 관리됩니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
selfUpdateUnsupported:
"이 데몬은 스스로 업데이트할 수 없습니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
update: "업데이트",
updateFailedTitle: "데몬 업데이트 실패",
},
contextWindow: {
noData: "컨텍스트 데이터 없음",
accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음",
@@ -2149,7 +2163,6 @@ export const ko: TranslationResources = {
days: "{{value}}일",
hours: "{{value}}시간",
minutes: "{{value}}분",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "마지막 업데이트: {{relativeTime}}",
@@ -2157,8 +2170,6 @@ export const ko: TranslationResources = {
resetTimePassed: "초기화 시간이 지났습니다",
resettingNow: "지금 재설정 중",
resetsIn: "{{duration}} 후 재설정",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}} {{time}}",
runsOutNow: "지금 한도에 도달",
runsOutIn: "{{duration}} 후 한도에 도달",
justNow: "방금",
@@ -2758,14 +2769,6 @@ export const ko: TranslationResources = {
},
agents: {
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
resumeOnStartup: {
title: "재시작 후 에이전트 재개",
info: "호스트가 중지될 때 작업 중이던 에이전트를 다시 열고 재개 메시지를 보냅니다. 정상 재시작과 비정상 종료 후 모두 적용됩니다.",
hint: "중단된 작업을 자동으로 계속합니다.",
prompt: "재개 메시지",
edit: "편집",
editAccessibilityLabel: "메시지 편집",
},
idleRuntimeCleanup: {
title: "유휴 에이전트 해제",
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
+13 -11
View File
@@ -2091,6 +2091,19 @@ export const ptBR: TranslationResources = {
sidebarCallout: {
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: {
noData: "Sem dados de contexto",
accessibilityNoData: "Janela de contexto: sem dados de contexto",
@@ -2172,7 +2185,6 @@ export const ptBR: TranslationResources = {
days: "{{value}} d",
hours: "{{value}} h",
minutes: "{{value}} min",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Última atualização {{relativeTime}}",
@@ -2180,8 +2192,6 @@ export const ptBR: TranslationResources = {
resetTimePassed: "O horário de redefinição passou",
resettingNow: "redefinindo agora",
resetsIn: "redefine em {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "esgota agora",
runsOutIn: "esgota em {{duration}}",
justNow: "agora mesmo",
@@ -2785,14 +2795,6 @@ export const ptBR: TranslationResources = {
},
agents: {
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: {
title: "Liberar agentes ociosos",
hint: "Libera memória após uma hora de inatividade.",
+12 -11
View File
@@ -2091,6 +2091,18 @@ export const ru: TranslationResources = {
sidebarCallout: {
dismiss: "Закрыть",
},
daemonOutdated: {
title: "Демон устарел",
versions: "На {{host}} работает демон {{daemonVersion}}. Версия приложения: {{appVersion}}.",
terminalsStayOpen: "Обновление перезапустит демон. Терминалы останутся открытыми.",
terminalsClose: "Обновление перезапустит демон и закроет открытые терминалы.",
desktopUpdateTerminals:
"Обновление Paseo Desktop оставляет терминалы открытыми в macOS и Linux и закрывает их в Windows.",
desktopManaged: "Этим демоном управляет Paseo Desktop. Обновите Paseo на том компьютере.",
selfUpdateUnsupported: "Этот демон не может обновиться сам. Обновите Paseo на том компьютере.",
update: "Обновить",
updateFailedTitle: "Не удалось обновить демон",
},
contextWindow: {
noData: "Нет данных о контексте",
accessibilityNoData: "Контекстное окно: нет данных о контексте",
@@ -2172,7 +2184,6 @@ export const ru: TranslationResources = {
days: "{{value}} дн.",
hours: "{{value}} ч",
minutes: "{{value}} мин",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Последнее обновление {{relativeTime}}",
@@ -2180,8 +2191,6 @@ export const ru: TranslationResources = {
resetTimePassed: "Время сброса прошло",
resettingNow: "сброс сейчас",
resetsIn: "сброс через {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "закончится сейчас",
runsOutIn: "закончится через {{duration}}",
justNow: "только что",
@@ -2795,14 +2804,6 @@ export const ru: TranslationResources = {
},
agents: {
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
resumeOnStartup: {
title: "Возобновлять агентов после перезапуска",
info: "Открывает агентов, которые работали при остановке хоста, и отправляет сообщение для продолжения. Применяется после обычного перезапуска и сбоя.",
hint: "Автоматически продолжать прерванную работу.",
prompt: "Сообщение для продолжения",
edit: "Редактировать",
editAccessibilityLabel: "Изменить сообщение",
},
idleRuntimeCleanup: {
title: "Освобождать неактивных агентов",
hint: "Освобождает память после часа бездействия.",
+12 -11
View File
@@ -2036,6 +2036,18 @@ export const zhCN: TranslationResources = {
sidebarCallout: {
dismiss: "关闭",
},
daemonOutdated: {
title: "守护进程版本过旧",
versions: "{{host}} 运行的守护进程为 {{daemonVersion}},此应用为 {{appVersion}}。",
terminalsStayOpen: "更新会重启守护进程,终端将保持打开。",
terminalsClose: "更新会重启守护进程,并关闭已打开的终端。",
desktopUpdateTerminals:
"更新 Paseo Desktop 时,macOS 和 Linux 上的终端保持打开,Windows 上的终端会被关闭。",
desktopManaged: "此守护进程由 Paseo Desktop 管理。请在那台电脑上更新 Paseo。",
selfUpdateUnsupported: "此守护进程无法自行更新。请在那台电脑上更新 Paseo。",
update: "更新",
updateFailedTitle: "守护进程更新失败",
},
contextWindow: {
noData: "暂无上下文数据",
accessibilityNoData: "上下文窗口:暂无上下文数据",
@@ -2117,7 +2129,6 @@ export const zhCN: TranslationResources = {
days: "{{value}}天",
hours: "{{value}}小时",
minutes: "{{value}}分钟",
pair: "{{first}}{{second}}",
},
timing: {
lastUpdated: "上次更新于 {{relativeTime}}",
@@ -2125,8 +2136,6 @@ export const zhCN: TranslationResources = {
resetTimePassed: "重置时间已过",
resettingNow: "正在重置",
resetsIn: "将在 {{duration}}后重置",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}} {{time}}",
runsOutNow: "即将用尽",
runsOutIn: "将在 {{duration}}后用尽",
justNow: "刚刚",
@@ -2716,14 +2725,6 @@ export const zhCN: TranslationResources = {
},
agents: {
unavailable: "连接到这个 Host 以管理 Agent",
resumeOnStartup: {
title: "重启后恢复智能体",
info: "重新打开主机停止时正在工作的智能体,并发送恢复提示。适用于正常重启和崩溃后的恢复。",
hint: "自动继续被中断的工作。",
prompt: "恢复提示",
edit: "编辑",
editAccessibilityLabel: "编辑提示",
},
idleRuntimeCleanup: {
title: "释放空闲 Agent",
hint: "空闲一小时后释放内存。",
@@ -65,7 +65,6 @@ import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { useProviderIcon } from "@/components/provider-icons";
import { BrowserToolsOptInCard } from "./browser-tools-card";
import { RestartRecoveryCard } from "./restart-recovery-card";
import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card";
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
@@ -293,7 +292,6 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
<InjectPaseoToolsCard serverId={serverId} />
<BrowserToolsOptInCard serverId={serverId} />
<AppendSystemPromptCard serverId={serverId} />
<RestartRecoveryCard serverId={serverId} />
<IdleRuntimeCleanupCard serverId={serverId} />
</SettingsSection>
) : (
@@ -1,199 +0,0 @@
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],
},
}));
+71 -28
View File
@@ -1,16 +1,10 @@
import { useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import {
formatAmount,
formatProviderUsageLabel,
formatResetLabel,
formatResetTime,
} from "./format";
import { UsageMeter } from "./meter";
import { UsageResetLine } from "./reset-line";
import type { UsageBalance } from "./types";
import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format";
import type { UsageBalance, UsageTone } from "./types";
import { useRelativeTimeTick } from "./use-relative-time-tick";
interface ResolvedBalance {
@@ -47,6 +41,19 @@ function resolveBalance({
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({
balance,
retained = false,
@@ -59,7 +66,11 @@ export function UsageBalanceBar({
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
const tone = balance.tone ?? "default";
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 (
<View style={styles.container}>
@@ -67,21 +78,22 @@ export function UsageBalanceBar({
<Text style={styles.label} numberOfLines={1}>
{formatProviderUsageLabel(balance.id, balance.label, t)}
</Text>
<Text
style={styles.amount}
numberOfLines={1}
ellipsizeMode="tail"
testID={`provider-usage-balance-${balance.id}-value`}
>
{amountText}
</Text>
<View style={styles.value} testID={`provider-usage-balance-${balance.id}-value`}>
<Text style={styles.amount} numberOfLines={1} ellipsizeMode="tail">
{amountText}
</Text>
{resetLabel ? (
<Text style={styles.reset} testID={`provider-usage-balance-${balance.id}-reset`}>
{resetLabel}
</Text>
) : null}
</View>
</View>
{usedPct != null ? <UsageMeter percent={usedPct} tone={tone} /> : null}
<UsageResetLine
countdown={resetLabel}
exactTime={resetTime}
testID={`provider-usage-balance-${balance.id}-reset`}
/>
{usedPct != null ? (
<View style={styles.track}>
<View style={fillStyle} />
</View>
) : null}
</View>
);
}
@@ -98,13 +110,44 @@ const styles = StyleSheet.create((theme) => ({
},
label: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
alignItems: "flex-end",
},
amount: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
amount: {
flexShrink: 1,
color: theme.colors.foreground,
reset: {
color: theme.colors.foregroundMuted,
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,
},
}));
+2 -67
View File
@@ -7,7 +7,6 @@ import {
formatPct,
formatProviderUsageLabel,
formatResetLabel,
formatResetTime,
formatRunsOutLabel,
} from "./format";
@@ -37,8 +36,8 @@ describe("formatAmount", () => {
const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
expect(formatPct(7, "en")).toBe("7%");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets in 2h");
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h");
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h");
await i18n.changeLanguage("ja");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
@@ -95,70 +94,6 @@ 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", () => {
const past = "2000-01-01T00:00:00.000Z";
expect(formatResetLabel(past, true)).toBe("Reset time passed");
+35 -68
View File
@@ -1,6 +1,5 @@
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { formatTimeOfDay } from "@/utils/time";
import type { UsageDisplayAs } from "./preferences";
import type { UsageBalanceUnit } from "./types";
@@ -51,17 +50,7 @@ export function formatDisplayPct(
: t("providerUsage.values.remaining", { amount: percentage });
}
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;
type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number };
function formatCount(value: number): string {
return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
@@ -70,34 +59,35 @@ function formatCount(value: number): string {
function relativeDuration(iso: string, now: number): RelativeDuration | null {
const diffMs = new Date(iso).getTime() - now;
if (!Number.isFinite(diffMs)) return null;
if (diffMs <= 0) return { kind: "now" };
const days = Math.floor(diffMs / DAY_MS);
const hours = Math.floor((diffMs % DAY_MS) / HOUR_MS);
const minutes = Math.floor((diffMs % HOUR_MS) / MINUTE_MS);
if (days > 0) return remaining({ unit: "days", count: days }, { unit: "hours", count: hours });
if (hours > 0)
return remaining({ unit: "hours", count: hours }, { unit: "minutes", count: minutes });
return remaining({ unit: "minutes", count: minutes });
if (diffMs <= 0) return { unit: "now" };
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return { unit: "days", count: diffDays };
if (diffHours > 0) return { unit: "hours", count: diffHours };
return { unit: "minutes", count: diffMinutes };
}
/** The major unit, plus the minor one unless it is zero. */
function remaining(major: DurationPart, minor?: DurationPart): RelativeDuration {
return { kind: "remaining", parts: minor && minor.count > 0 ? [major, minor] : [major] };
}
function formatDurationPart(part: DurationPart, t: TFunction): string {
return t(`providerUsage.duration.${part.unit}`, {
count: part.count,
value: formatCount(part.count),
});
}
function formatRelativeDuration(
duration: Extract<RelativeDuration, { kind: "remaining" }>,
t: TFunction,
): string {
const [first, second] = duration.parts.map((part) => formatDurationPart(part, t));
return second ? t("providerUsage.duration.pair", { first, second }) : first;
function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null {
switch (duration.unit) {
case "now":
return null;
case "days":
return t("providerUsage.duration.days", {
count: duration.count,
value: formatCount(duration.count),
});
case "hours":
return t("providerUsage.duration.hours", {
count: duration.count,
value: formatCount(duration.count),
});
case "minutes":
return t("providerUsage.duration.minutes", {
count: duration.count,
value: formatCount(duration.count),
});
}
}
export function formatResetLabel(
@@ -109,34 +99,10 @@ export function formatResetLabel(
if (!iso) return null;
const rel = relativeDuration(iso, now);
if (!rel) return null;
if (rel.kind === "now" && retained) return t("providerUsage.timing.resetTimePassed");
if (rel.kind === "now") return t("providerUsage.timing.resettingNow");
return t("providerUsage.timing.resetsIn", { duration: formatRelativeDuration(rel, t) });
}
/**
* 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 });
if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed");
if (rel.unit === "now") return t("providerUsage.timing.resettingNow");
const duration = formatRelativeDuration(rel, t);
return duration ? t("providerUsage.timing.resetsIn", { duration }) : null;
}
export function formatRunsOutLabel(
@@ -147,8 +113,9 @@ export function formatRunsOutLabel(
if (!iso) return null;
const rel = relativeDuration(iso, now);
if (!rel) return null;
if (rel.kind === "now") return t("providerUsage.timing.runsOutNow");
return t("providerUsage.timing.runsOutIn", { duration: formatRelativeDuration(rel, t) });
if (rel.unit === "now") return t("providerUsage.timing.runsOutNow");
const duration = formatRelativeDuration(rel, t);
return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null;
}
export function formatAgo(
+2 -2
View File
@@ -46,8 +46,8 @@ export function UsageMeter({
const styles = StyleSheet.create((theme) => ({
track: {
height: 6,
borderRadius: 3,
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
-48
View File
@@ -1,48 +0,0 @@
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 },
}));
+10 -1
View File
@@ -192,7 +192,12 @@ function PinnedUsageLine({
<Fragment key={window.key}>
{layout === "meters" ? (
<View style={[styles.meterSlot, leadStyle]}>
<UsageMeter percent={window.percent} tone={window.tone} quiet />
<UsageMeter
percent={window.percent}
tone={window.tone}
quiet
style={styles.meter}
/>
</View>
) : null}
<Text
@@ -264,6 +269,10 @@ const styles = StyleSheet.create((theme) => ({
minWidth: MIN_METER_WIDTH,
marginRight: METER_GAP,
},
meter: {
height: 6,
borderRadius: 3,
},
percent: {
flexShrink: 0,
color: theme.colors.foreground,
+86 -38
View File
@@ -1,24 +1,29 @@
import { Pin } from "lucide-react-native";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
formatDisplayPct,
formatProviderUsageLabel,
formatResetLabel,
formatResetTime,
formatRunsOutLabel,
} from "./format";
import { UsageMeter } from "./meter";
import { displayPercent, usageWindowRowLabel } from "./model";
import { UsageResetLine } from "./reset-line";
import type { UsageDisplayAs } from "./preferences";
import { windowTone } from "./tone";
import type { UsageTone, UsageWindow } from "./types";
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({
window,
displayAs,
@@ -49,12 +54,9 @@ export function UsageWindowBar({
window.runsOutAt != null &&
window.shortfallPct != null &&
(!retained || Date.parse(window.runsOutAt) > now);
const countdown = isAtRisk
const trailing = isAtRisk
? formatRunsOutLabel(window.runsOutAt, 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 =
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
@@ -62,57 +64,60 @@ export function UsageWindowBar({
const content = {
label: formatProviderUsageLabel(window.id, window.label, t),
value,
countdown,
exactTime,
trailing,
isAtRisk,
percent: shownPct ?? 0,
tone,
pinned,
};
// Same padding as the pinnable row, so bars line up in both modes.
if (!pinnable) {
return (
<View style={styles.row}>
<WindowRowContent {...content} pinnable={false} />
<WindowRowContent {...content} highlight={styles.highlightNone} pinnable={false} />
</View>
);
}
// The whole row pins the window to the sidebar Usage item; the pin glyph is the only sign of it.
// The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same
// padding so toggling only changes the background.
return (
<Pressable
onPress={onTogglePin}
accessibilityRole="checkbox"
accessibilityLabel={usageWindowRowLabel({
pinLabel,
value,
trailing: [countdown, exactTime].filter(Boolean).join(" · "),
})}
accessibilityLabel={usageWindowRowLabel({ pinLabel, value, trailing })}
accessibilityState={accessibilityState}
aria-checked={pinned}
style={styles.row}
testID={pinTestID}
>
<WindowRowContent {...content} pinnable />
{({ hovered }: { hovered?: boolean }) => (
<WindowRowContent
{...content}
highlight={highlightStyle(pinned, Boolean(hovered))}
pinnable
/>
)}
</Pressable>
);
}
function WindowRowContent({
highlight,
label,
value,
countdown,
exactTime,
trailing,
isAtRisk,
percent,
tone,
pinnable,
pinned,
}: {
highlight: StyleProp<ViewStyle>;
label: string;
value: string;
countdown: string | null;
exactTime: string | null;
trailing: string | null | undefined;
isAtRisk: boolean;
percent: number;
tone: UsageTone;
@@ -121,17 +126,24 @@ function WindowRowContent({
}) {
return (
<>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
<Text style={styles.value} numberOfLines={1}>
{value}
</Text>
<View style={highlight} pointerEvents="none" />
<View style={styles.contentRow}>
<View style={styles.windowContent}>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{label}
</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}
</View>
<UsageMeter percent={percent} tone={tone} />
<UsageResetLine countdown={countdown} exactTime={exactTime} atRisk={isAtRisk} />
</>
);
}
@@ -141,9 +153,9 @@ const ThemedPin = withUnistyles(Pin);
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
const { t } = useTranslation();
const iconMapping = useMemo(
() => (theme: { colors: { foreground: string; foregroundMuted: string } }) => ({
color: pinned ? theme.colors.foreground : theme.colors.foregroundMuted,
fill: pinned ? theme.colors.foreground : "none",
() => (theme: { colors: { foregroundMuted: string } }) => ({
color: theme.colors.foregroundMuted,
fill: pinned ? theme.colors.foregroundMuted : "none",
}),
[pinned],
);
@@ -167,23 +179,59 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
}
const styles = StyleSheet.create((theme) => ({
row: { gap: 3, paddingVertical: theme.spacing[1.5] },
contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
windowContent: { flex: 1, gap: 3 },
pin: { width: 12, alignItems: "center" },
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: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: theme.spacing[2],
},
label: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
value: {
flexShrink: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
reset: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
atRisk: {
color: theme.colors.statusDanger,
fontWeight: theme.fontWeight.normal,
},
}));
+1 -6
View File
@@ -125,11 +125,6 @@ function getTimeFormatter(): Intl.DateTimeFormat {
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.
* - Same day: "10:11 PM" or "22:11" depending on user preference
@@ -137,7 +132,7 @@ export function formatTimeOfDay(date: Date): string {
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
*/
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
const time = formatTimeOfDay(date);
const time = getTimeFormatter().format(date);
const daysAgo = localCalendarDaysBetween(date, now);
if (daysAgo === 0) {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/server": "0.14.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/server": "0.14.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -77,8 +77,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -88,8 +88,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
-18
View File
@@ -255,24 +255,6 @@ describe("lifecycle hook SDK types", () => {
typeErrors(`
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
export default function contribute(server: PluginServerContext) {
server.addHook("worktree.create.before", async ({ request }) => {
if (request.kind === "checkout-branch") {
// @ts-expect-error Branch checkout has no branch-off base.
request.baseRef;
} else {
const base: string = request.baseRef;
}
const branch: string = request.branch;
// @ts-expect-error Repository identity is read-only.
request.repoRoot = "/other";
// @ts-expect-error Source checkout identity is read-only.
request.sourceCheckout = "/other";
if (request.kind === "checkout-change-request") {
const number: number = request.changeRequestNumber;
}
return { ...request, branch: "feature", baseRef: "main", slug: "feature", path: "/work/feature" };
});
server.addHook("worktree.create.before", async () => {});
server.addHook("worktree.create.after", async ({ worktree }) => {
const path: string = worktree.path;
const branch: string = worktree.branch;
-1
View File
@@ -21,7 +21,6 @@ export type {
PluginHookContext,
PluginHookWorkspace,
PluginHookWorktree,
PluginWorktreeCreateRequest,
PluginHookAgent,
PluginSessionOpenRequest,
PluginTurnOutcome,
-36
View File
@@ -27,40 +27,6 @@ export interface PluginHookWorktree {
sourceCheckout: string;
}
/** Resolved creation options before Git fetches refs or chooses a unique branch name. */
export type PluginWorktreeCreateRequest = {
readonly repoRoot: string;
readonly sourceCheckout: string;
branch: string;
slug: string;
path: string;
} & (
| { kind: "branch-off"; baseRef: string }
| { kind: "checkout-branch" }
| {
kind: "checkout-change-request";
baseRef: string;
forge: string;
changeRequestNumber: number;
headRef: string;
headRepositoryOwner?: string;
headRepository?: string;
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
| {
kind: "checkout-github-pr";
baseRef: string;
githubPrNumber: number;
headRef: string;
headRepositoryOwner?: string;
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
);
export interface PluginHookAgent {
id: string;
workspaceId: string | null;
@@ -115,7 +81,6 @@ export interface PluginHookEvents {
}
export interface PluginHookRequests {
"worktree.create.before": PluginWorktreeCreateRequest;
"agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> };
"agent.open.before": PluginSessionOpenRequest;
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
@@ -175,7 +140,6 @@ export const pluginHookRegistry = {
"workspace.create.before": { waits: true },
"workspace.create.after": { waits: false },
"workspace.archive.after": { waits: false },
"worktree.create.before": { waits: true },
"worktree.create.after": { waits: true },
"agent.create.before": { waits: true },
"agent.create.after": { waits: false },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
-7
View File
@@ -237,9 +237,6 @@ export const MutableDaemonConfigSchema = z
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, keep optional while old daemons are supported.
// Persisted host-wide value, applied live by the daemon.
idleRuntimeCleanup: z.boolean().optional(),
// COMPAT(restartRecovery): added in v0.14.0, keep optional for older hosts.
resumeOnStartup: z.boolean().optional(),
resumePrompt: z.string().optional(),
appendSystemPrompt: z.string().default(""),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(),
@@ -265,8 +262,6 @@ export const MutableDaemonConfigPatchSchema = z
autoArchiveAfterMerge: z.boolean().optional(),
enableTerminalAgentHooks: z.boolean().optional(),
idleRuntimeCleanup: z.boolean().optional(),
resumeOnStartup: z.boolean().optional(),
resumePrompt: z.string().optional(),
appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(),
@@ -3736,8 +3731,6 @@ export const ServerInfoStatusPayloadSchema = z
skillManagement: z.boolean().optional(),
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
idleRuntimeCleanupSetting: z.boolean().optional(),
// COMPAT(restartRecovery): added in v0.14.0, remove gate after 2027-04-28.
resumeOnStartupSetting: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": z.boolean().optional(),
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/highlight": "0.14.0",
"@getpaseo/plugin": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/highlight": "0.14.0-beta.1",
"@getpaseo/plugin": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -135,7 +135,6 @@ export async function ensureAgentLoaded(
labels: record.labels,
workspaceId: record.workspaceId,
owner: record.owner,
env: record.launchEnv,
});
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
}
@@ -956,131 +956,6 @@ test("unavailable steer interrupts once and starts one replacement turn", async
}
});
test("steers a send that arrives while the previous turn is still starting", async () => {
const startHeld = deferred<void>();
class SlowStartSteeringSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
await startHeld.promise;
return await super.startTurn(prompt);
}
}
const session = new SlowStartSteeringSession({
provider: "codex",
cwd: process.cwd(),
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-starting-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const steerOptions = (clientMessageId: string) => ({
replaceRunning: true,
activeTurnBehavior: "steer" as const,
runOptions: { clientMessageId },
});
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
const second = startAgentRun(
manager,
agent.id,
"SECOND",
logger,
steerOptions("second-client"),
);
startHeld.resolve();
await expect(second).resolves.toEqual({ disposition: "steered" });
expect(session.startPrompts).toEqual(["FIRST"]);
expect(session.interruptCount).toBe(0);
expect(
manager
.fetchTimeline(agent.id, { limit: 0 })
.rows.filter((row) => row.item.type === "user_message")
.map((row) => ({
text: row.item.type === "user_message" ? row.item.text : null,
turnId: row.turnId,
})),
).toEqual([
{ text: "FIRST", turnId: "active-turn-1" },
{ text: "SECOND", turnId: "active-turn-1" },
]);
} finally {
startHeld.resolve();
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("starts its own turn when the previous turn fails to start under a steer send", async () => {
const startHeld = deferred<void>();
class FailingFirstStartSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
if (prompt === "FIRST") {
this.startPrompts.push(prompt);
await startHeld.promise;
throw new Error("first start failed");
}
return await super.startTurn(prompt);
}
}
const session = new FailingFirstStartSession({
provider: "codex",
cwd: process.cwd(),
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const steerOptions = (clientMessageId: string) => ({
replaceRunning: true,
activeTurnBehavior: "steer" as const,
runOptions: { clientMessageId },
});
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
const second = startAgentRun(
manager,
agent.id,
"SECOND",
logger,
steerOptions("second-client"),
);
startHeld.resolve();
await expect(second).resolves.toEqual({ disposition: "turn_started" });
await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"]));
expect(session.interruptCount).toBe(0);
} finally {
startHeld.resolve();
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("orders an accepted steer before output emitted while acknowledgement is pending", async () => {
const entered = deferred<void>();
const release = deferred<void>();
@@ -3054,92 +2929,6 @@ test("createAgent passes daemon launch env through the provider launch context",
});
});
test.each([
{ provider: "codex", hasHandle: true },
{ provider: "opencode", hasHandle: true },
{ provider: "codex", hasHandle: false },
{ provider: "opencode", hasHandle: false },
])(
"$provider preserves explicit launch env across reloads and storage-backed resumes (handle: $hasHandle)",
async ({ provider, hasHandle }) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-"));
const storagePath = join(workdir, "agents");
class LaunchEnvClient extends TestAgentClient {
lastLaunchContext: AgentLaunchContext | undefined;
override async createSession(
config: AgentSessionConfig,
context?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = context;
const session = await super.createSession(config);
session.describePersistence = () => ({ provider, sessionId: session.id });
return session;
}
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
context?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = context;
const session = await super.resumeSession(handle, config, context);
session.describePersistence = () => ({ provider, sessionId: handle.sessionId });
return session;
}
}
const client = new LaunchEnvClient(provider);
let storage = new AgentStorage(storagePath, logger);
let manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
const env = { XDG_DATA_HOME: join(workdir, "agent-data"), CUSTOM_EMPTY: "" };
const expectedEnv = { ...env };
const agent = await manager.createAgent({ provider, cwd: workdir }, undefined, {
workspaceId: undefined,
env,
});
onTestFinished(async () => {
await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
});
env.XDG_DATA_HOME = "caller-mutated";
await manager.closeAgent(agent.id);
await manager.flush();
if (!hasHandle) {
const record = await storage.get(agent.id);
if (!record) throw new Error("Created agent was not persisted");
await storage.upsert({ ...record, persistence: null });
}
// Fresh storage and manager instances must read the environment from disk.
for (let restart = 0; restart < 2; restart += 1) {
storage = new AgentStorage(storagePath, logger);
manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
const resumed = await ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(client.lastLaunchContext).toEqual({
agentId: agent.id,
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
});
await manager.reloadAgentSession(agent.id);
expect(client.lastLaunchContext).toEqual({
agentId: agent.id,
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
});
expect(toAgentPayload(resumed)).not.toHaveProperty("launchEnv");
expect(toAgentPayload(resumed).persistence?.metadata).not.toHaveProperty("launchEnv");
await manager.closeAgent(agent.id);
await manager.flush();
}
},
);
test("createAgent passes persistSession to provider create options", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -421,8 +421,6 @@ interface ManagedAgentBase {
owner?: AgentOwner;
capabilities: AgentCapabilityFlags;
config: AgentSessionConfig;
/** Explicit launch overrides, before per-opening hooks and daemon-owned variables. */
launchEnv?: Record<string, string>;
runtimeInfo?: AgentRuntimeInfo;
createdAt: Date;
updatedAt: Date;
@@ -1401,7 +1399,6 @@ export class AgentManager {
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
launchEnv: options.env ? { ...options.env } : undefined,
historyPrimed: true,
});
if (!agent.internal) {
@@ -1490,11 +1487,10 @@ export class AgentManager {
: resumeOptions;
const purpose = currentResumeOptions?.purpose ?? "interactive";
const launchEnv = record?.launchEnv;
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
{ purpose, env: launchEnv },
{ purpose },
);
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
@@ -1509,7 +1505,7 @@ export class AgentManager {
client,
storedConfig.cwd,
paseoToolPolicy,
launchEnv,
undefined,
{
reason: "resume",
purpose,
@@ -1528,7 +1524,6 @@ export class AgentManager {
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
launchEnv,
persistence: handle,
restoring: true,
});
@@ -1666,7 +1661,6 @@ export class AgentManager {
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
refreshConfig,
agentId,
{ env: existing.launchEnv },
);
const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId);
const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId);
@@ -1675,7 +1669,7 @@ export class AgentManager {
client,
storedConfig.cwd,
paseoToolPolicy,
existing.launchEnv,
undefined,
{
reason: "refresh",
purpose: "interactive",
@@ -1726,7 +1720,6 @@ export class AgentManager {
labels: existing.labels,
workspaceId: existing.workspaceId,
owner: existing.owner,
launchEnv: existing.launchEnv,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
lastUserMessageAt: existing.lastUserMessageAt,
@@ -2734,7 +2727,7 @@ export class AgentManager {
throw error;
}
if (isStaleProviderSessionError(error)) {
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message });
pendingRun.start = { status: "failed", error: error.message };
agent.pendingReplacement = false;
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
this.runs.settleForegroundRun(agentId, pendingRun.token);
@@ -2742,24 +2735,20 @@ export class AgentManager {
}
agent.pendingReplacement = false;
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg });
try {
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
messageId: options.clientMessageId,
});
}
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
pendingRun.start = { status: "failed", error: errorMsg };
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
messageId: options.clientMessageId,
});
this.finalizeForegroundTurn(agent);
} finally {
// A steer waiting on this start is released only once the run is cleared.
this.runs.settleForegroundRun(agentId, pendingRun.token);
}
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
});
this.finalizeForegroundTurn(agent);
this.runs.settleForegroundRun(agentId, pendingRun.token);
throw error;
}
}
@@ -2836,7 +2825,7 @@ export class AgentManager {
agent.pendingReplacement = false;
}
const turnStartedAt = new Date();
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
pendingRun.start = { status: "started", turnId };
agent.activeForegroundTurnId = turnId;
this.openActiveTurn(agent, turnId, turnStartedAt);
agent.lifecycle = "running";
@@ -3045,9 +3034,6 @@ export class AgentManager {
prompt: AgentPromptInput,
options?: AgentSteerOptions,
): Promise<ActiveTurnSteerDispatchResult> {
// A turn that is still starting has no id to steer yet. Replacing it would drop its
// prompt, so wait until it is accepted (or fails) and steer the turn it became.
await this.runs.waitForForegroundStart(agentId);
const agent = this.requireSessionAgent(agentId);
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
if (!expectedTurnId) {
@@ -3777,7 +3763,6 @@ export class AgentManager {
publishWhenReady?: boolean;
workspaceId?: string;
owner?: AgentOwner;
launchEnv?: Record<string, string>;
},
): Promise<ManagedAgent> {
let registered = false;
@@ -3963,7 +3948,6 @@ export class AgentManager {
persistence?: AgentPersistenceHandle;
workspaceId?: string;
owner?: AgentOwner;
launchEnv?: Record<string, string>;
}
| undefined;
}): ActiveManagedAgent {
@@ -3974,7 +3958,6 @@ export class AgentManager {
cwd: config.cwd,
workspaceId: options?.workspaceId,
owner: options?.owner,
launchEnv: options?.launchEnv,
session,
capabilities: session.capabilities,
config,
@@ -95,7 +95,6 @@ export function toStoredAgentRecord(
lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
runtimeInfo,
features: normalizeFeatures(agent.features),
persistence,
@@ -1,51 +0,0 @@
import { describe, expect, test } from "vitest";
import { AgentRunState } from "./agent-run-state.js";
function track(promise: Promise<void>): { readonly resolved: boolean } {
const state = { resolved: false };
void (async () => {
await promise;
state.resolved = true;
})();
return state;
}
async function flushMicrotasks(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
describe("waitForForegroundStart", () => {
test("resolves once the starting turn has an id", async () => {
const runs = new AgentRunState();
const run = runs.createPendingRun("agent");
const wait = track(runs.waitForForegroundStart("agent"));
await flushMicrotasks();
expect(wait.resolved).toBe(false);
runs.settleForegroundStart(run, { status: "started", turnId: "turn-1" });
await flushMicrotasks();
expect(wait.resolved).toBe(true);
});
test("waits for a failed start's run to be cleared", async () => {
const runs = new AgentRunState();
const run = runs.createPendingRun("agent");
const wait = track(runs.waitForForegroundStart("agent"));
runs.settleForegroundStart(run, { status: "failed", error: "start failed" });
await flushMicrotasks();
expect(wait.resolved).toBe(false);
expect(runs.hasPendingRun("agent")).toBe(true);
runs.settleForegroundRun("agent", run.token);
await flushMicrotasks();
expect(wait.resolved).toBe(true);
});
test("resolves at once when no turn is starting", async () => {
const runs = new AgentRunState();
await expect(runs.waitForForegroundStart("agent")).resolves.toBeUndefined();
});
});
@@ -10,18 +10,14 @@ export interface ForegroundTurnWaiter {
resolveSettled: () => void;
}
type ForegroundRunStart =
| { status: "pending" }
| { status: "started"; turnId: string }
| { status: "failed"; error: string };
export interface PendingForegroundRun {
token: string;
kind: "foreground";
stagedEvents: AgentStreamEvent[];
start: ForegroundRunStart;
turnStartedPromise: Promise<void>;
resolveTurnStarted: () => void;
start:
| { status: "pending" }
| { status: "started"; turnId: string }
| { status: "failed"; error: string };
settled: boolean;
settledPromise: Promise<void>;
resolveSettled: () => void;
@@ -58,28 +54,6 @@ export class AgentRunState {
return run?.kind === "foreground" ? run : null;
}
settleForegroundStart(
run: PendingForegroundRun,
start: Exclude<ForegroundRunStart, { status: "pending" }>,
): void {
run.start = start;
if (start.status === "started") {
run.resolveTurnStarted();
}
}
/**
* Resolves once the agent's foreground run has a turn id or has ended. A failed start
* resolves only once its run is cleared, so callers never see a failed run still tracked.
*/
async waitForForegroundStart(agentId: string): Promise<void> {
const run = this.getPendingRun(agentId);
if (run?.start.status !== "pending") {
return;
}
await Promise.race([run.turnStartedPromise, run.settledPromise]);
}
hasPendingRun(agentId: string): boolean {
return this.getPendingRun(agentId) !== null;
}
@@ -303,16 +277,10 @@ export class ForegroundTurnStream {
}
function createPendingForegroundRun(): PendingForegroundRun {
let resolveTurnStarted!: () => void;
const turnStartedPromise = new Promise<void>((resolvePromise) => {
resolveTurnStarted = resolvePromise;
});
return {
...createTrackedRunState(),
kind: "foreground",
start: { status: "pending" },
turnStartedPromise,
resolveTurnStarted,
stagedEvents: [],
};
}
@@ -54,10 +54,8 @@ const STORED_AGENT_SCHEMA = z.object({
title: z.string().nullable().optional(),
labels: z.record(z.string(), z.string()).default({}),
lastStatus: AgentStatusSchema.default("closed"),
resumeOnStartup: z.boolean().optional(),
lastModeId: z.string().nullable().optional(),
config: SERIALIZABLE_CONFIG_SCHEMA,
launchEnv: z.record(z.string(), z.string()).optional(),
runtimeInfo: z
.object({
provider: z.string(),
@@ -261,8 +259,6 @@ export class AgentStorage {
if (existing && existing.archivedAt !== undefined) {
record.archivedAt = existing.archivedAt;
}
record.resumeOnStartup =
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
return record;
});
}

Some files were not shown because too many files have changed in this diff Show More