diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fd290dfa..7d48d9c289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.13.0-beta.2 - 2026-10-23 +## 0.13.0 - 2026-10-24 Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. @@ -23,7 +23,7 @@ paseo host add 'relay://...' --label laptop ### Improved -- Changed sidebar workspaces to sort by Created, with Last prompt under Display → Sort; workspaces no longer drag to reorder ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771)) - Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635)) - Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690)) - Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611)) @@ -32,6 +32,7 @@ paseo host add 'relay://...' --label laptop - Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589)) - Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch)) - Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693)) +- Updated pinned ACP agents to their latest releases ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck)) - Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639)) - Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu)) - Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561)) @@ -48,10 +49,11 @@ paseo host add 'relay://...' --label laptop - Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373)) - Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579)) - Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368)) -- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474)) +- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger)) - Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685)) - Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477)) - Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630)) +- Fixed reopened ACP plugin agents showing an empty chat ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream)) - Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92)) ### Fixed @@ -66,6 +68,11 @@ paseo host add 'relay://...' --label laptop - Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681)) - Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77)) - Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608)) +- Fixed a new workspace's pin or title disappearing from the sidebar until the next update ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024)) +- Fixed Claude background command notifications appearing twice after a daemon restart ([#7737](https://github.com/getpaseo/paseo/pull/7737)) +- Fixed voice replies skipping later sentences with OpenAI-compatible speech servers ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian)) +- Fixed Cursor usage showing bonus spend as overage instead of the quota percentages ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia)) +- Fixed the Android tablet taskbar covering the sidebar's Add project, Hosts, Help and Settings ([#7768](https://github.com/getpaseo/paseo/pull/7768)) - Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688)) - Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan)) - Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547)) @@ -73,7 +80,7 @@ paseo host add 'relay://...' --label laptop - Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph)) - Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589)) - Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589)) -- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429)) +- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701)) - Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362)) - Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy)) - Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365)) diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 6c82382605..09080be3bc 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,6 +1,6 @@ # 更新日志 -## 0.13.0-beta.2 - 2026-10-23 +## 0.13.0 - 2026-10-24 折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。 @@ -23,7 +23,7 @@ paseo host add 'relay://...' --label laptop ### 改进 -- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”;工作区不再支持拖动排序 ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771)) - 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635)) - Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690)) - 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611)) @@ -32,6 +32,7 @@ paseo host add 'relay://...' --label laptop - 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) - 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch)) - 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693)) +- 已固定版本的 ACP Agent 更新到最新版本 ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck)) - 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639)) - 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu)) - Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561)) @@ -48,10 +49,11 @@ paseo host add 'relay://...' --label laptop - 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373)) - `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579)) - 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368)) -- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474)) +- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口(包括 `useRevealedText`)仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger)) - 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685)) - turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477)) - 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630)) +- 修复了重新打开的 ACP 插件 Agent 聊天为空的问题 ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream)) - 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92)) ### 修复 @@ -66,6 +68,11 @@ paseo host add 'relay://...' --label laptop - 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681)) - 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77)) - 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608)) +- 修复了新工作区的置顶或标题在下次更新前从侧边栏消失的问题 ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024)) +- 修复了 daemon 重启后 Claude 后台命令通知出现两次的问题 ([#7737](https://github.com/getpaseo/paseo/pull/7737)) +- 修复了使用 OpenAI 兼容语音服务时语音回复跳过后续句子的问题 ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian)) +- 修复了 Cursor 用量将赠送额度消费显示为超额、而不是配额百分比的问题 ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia)) +- 修复了 Android 平板任务栏遮挡侧边栏中添加项目、主机、帮助和设置的问题 ([#7768](https://github.com/getpaseo/paseo/pull/7768)) - 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688)) - 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan)) - 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547)) @@ -73,7 +80,7 @@ paseo host add 'relay://...' --label laptop - 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph)) - 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) - 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) -- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429)) +- 修复了 Android 上不受信任的证书显示 "Host not found"、无法解析的主机名显示 TLS 建议的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701)) - 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362)) - 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy)) - 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365)) diff --git a/fastlane/metadata/android/en-US/changelogs/130001.txt b/fastlane/metadata/android/en-US/changelogs/130001.txt new file mode 100644 index 0000000000..f28b4c4040 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130001.txt @@ -0,0 +1,15 @@ +Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. + +Connect your daemon to another machine, then ask an agent to delegate work there: + +`sh + +paseo host add 192.168.1.20:6767 --label studio + +paseo host add 'ssh://me@build-box' --label build-box + +paseo host add 'relay://...' --label laptop + +` + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/130002.txt b/fastlane/metadata/android/en-US/changelogs/130002.txt new file mode 100644 index 0000000000..f28b4c4040 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130002.txt @@ -0,0 +1,15 @@ +Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. + +Connect your daemon to another machine, then ask an agent to delegate work there: + +`sh + +paseo host add 192.168.1.20:6767 --label studio + +paseo host add 'ssh://me@build-box' --label build-box + +paseo host add 'relay://...' --label laptop + +` + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/130003.txt b/fastlane/metadata/android/en-US/changelogs/130003.txt new file mode 100644 index 0000000000..f28b4c4040 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130003.txt @@ -0,0 +1,15 @@ +Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. + +Connect your daemon to another machine, then ask an agent to delegate work there: + +`sh + +paseo host add 192.168.1.20:6767 --label studio + +paseo host add 'ssh://me@build-box' --label build-box + +paseo host add 'relay://...' --label laptop + +` + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/130004.txt b/fastlane/metadata/android/en-US/changelogs/130004.txt new file mode 100644 index 0000000000..f28b4c4040 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130004.txt @@ -0,0 +1,15 @@ +Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. + +Connect your daemon to another machine, then ask an agent to delegate work there: + +`sh + +paseo host add 192.168.1.20:6767 --label studio + +paseo host add 'ssh://me@build-box' --label build-box + +paseo host add 'relay://...' --label laptop + +` + +Full notes: https://paseo.sh/changelog diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index d345683e19..28e353268d 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU= +sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg= diff --git a/package-lock.json b/package-lock.json index c93f050b23..114a2b59fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.13.0-beta.2", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.13.0-beta.2", + "version": "0.13.0", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -35695,7 +35695,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37961,12 +37961,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", - "@getpaseo/server": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/protocol": "0.13.0", + "@getpaseo/server": "0.13.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -38213,10 +38213,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { - "@getpaseo/protocol": "0.13.0-beta.2", - "@getpaseo/relay": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0", + "@getpaseo/relay": "0.13.0", "ws": "^8.20.0", "zod": "^4.4.3" }, @@ -38228,7 +38228,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.13.0-beta.2", + "version": "0.13.0", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -38472,7 +38472,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.0-beta.2", + "version": "0.13.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -42124,7 +42124,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -42153,15 +42153,15 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "i18next": "^26.3.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/protocol": "0.13.0", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -42171,8 +42171,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/protocol": "0.13.0", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -42194,7 +42194,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -42221,7 +42221,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@noble/ciphers": "^2.4.0", "@noble/curves": "^2.4.0", @@ -42442,16 +42442,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.13.0-beta.2", - "@getpaseo/highlight": "0.13.0-beta.2", - "@getpaseo/plugin": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", - "@getpaseo/relay": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/highlight": "0.13.0", + "@getpaseo/plugin": "0.13.0", + "@getpaseo/protocol": "0.13.0", + "@getpaseo/relay": "0.13.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -43372,7 +43372,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -44982,7 +44982,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index 583a88c406..97a224ef4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.13.0-beta.2", + "version": "0.13.0", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/e2e/browser/new-workspace-live-pin.spec.ts b/packages/app/e2e/browser/new-workspace-live-pin.spec.ts new file mode 100644 index 0000000000..3ed0755fc9 --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-live-pin.spec.ts @@ -0,0 +1,100 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { + delayBrowserAgentCreatedStatus, + openNewWorkspaceComposer, + submitNewWorkspacePrompt, +} from "../support/helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +// The creation response carries the descriptor captured when the workspace was provisioned. With an +// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's +// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The +// late response must not put the old, unpinned row back. + +function pinnedRowTestId(workspaceId: string): string { + return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; +} + +async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise { + let createdId: string | undefined; + await expect + .poll( + async () => { + const { entries } = await seeded.client.fetchWorkspaces({ + filter: { projectId: seeded.projectId }, + }); + createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id; + return createdId; + }, + { timeout: 30_000 }, + ) + .toBeTruthy(); + return createdId!; +} + +// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts. +async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise> { + await page.evaluate((rowTestId) => { + const state = { missingFrames: 0, stopped: false }; + (window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state; + const sample = () => { + if (state.stopped) return; + const section = document.querySelector('[data-testid="sidebar-pinned-section"]'); + if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1; + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }, testId); + return () => + page.evaluate(() => { + const state = ( + window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } } + ).__pinnedRowWatch; + state.stopped = true; + return state.missingFrames; + }); +} + +test.describe("New workspace live pin", () => { + test("keeps a pin made before the creation response arrives", async ({ page }) => { + test.setTimeout(120_000); + const creation = await delayBrowserAgentCreatedStatus(page); + const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, seeded); + await submitNewWorkspacePrompt(page, "Pin me while I start"); + + await creation.waitForCreateRequest(); + // The response is held here, carrying the descriptor from before the pin. + await creation.waitForDelayedCreatedStatus(); + const workspaceId = await findCreatedWorkspaceId(seeded); + const rowTestId = pinnedRowTestId(workspaceId); + + await seeded.client.setWorkspacePinned(workspaceId, true); + const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId); + await expect(pinnedRow).toBeVisible({ timeout: 10_000 }); + + const stopWatching = await watchPinnedRow(page, rowTestId); + creation.release(); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({ + timeout: 30_000, + }); + // Leave time for the released response to be merged and rendered. + await page.waitForTimeout(1_000); + + expect(await stopWatching()).toBe(0); + await expect(pinnedRow).toBeVisible(); + } finally { + creation.release(); + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts index bc8e1eedec..165bb168cd 100644 --- a/packages/app/e2e/browser/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -43,6 +43,8 @@ import { } from "../support/helpers/settings"; import { getServerId } from "../support/helpers/server-id"; import { expectAppRoute } from "../support/helpers/route-assertions"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; async function openWorkspace( page: import("@playwright/test").Page, @@ -53,6 +55,74 @@ async function openWorkspace( } test.describe("Settings sidebar navigation", () => { + test("opening Settings closes the composer model selector until it is opened again", async ({ + page, + withWorkspace, + }, testInfo) => { + const workspace = await withWorkspace({ prefix: "settings-model-selector-" }); + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + + const selector = page.getByTestId("combined-model-selector").filter({ visible: true }); + const popover = page.getByTestId("combobox-desktop-container"); + await selector.click(); + await expect(popover).toBeVisible(); + + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+,`); + await expect(page).toHaveURL(/\/settings\/general$/); + await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") }); + await expect(popover).toHaveCount(0); + + await clickSettingsBackToWorkspace(page); + await expect(selector).toBeVisible(); + await expect(popover).toHaveCount(0); + + await selector.click(); + await expect(popover).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(popover).toHaveCount(0); + }); + + test("opening Settings closes an existing chat's model selector until it is opened again", async ({ + page, + }, testInfo) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "settings-existing-model-", + title: "Settings model selector", + }); + try { + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + + const selector = page.getByTestId("combined-model-selector").filter({ visible: true }); + const popover = page.getByTestId("combobox-desktop-container"); + await selector.click(); + await expect(popover).toBeVisible(); + + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+,`); + await expect(page).toHaveURL(/\/settings\/general$/); + await page.screenshot({ + path: testInfo.outputPath("settings-after-existing-model-selector.png"), + }); + await expect(popover).toHaveCount(0); + + await clickSettingsBackToWorkspace(page); + await expect(selector).toBeVisible(); + await expect(popover).toHaveCount(0); + + await selector.click(); + await expect(popover).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(popover).toHaveCount(0); + } finally { + await workspace.cleanup(); + } + }); + test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => { await gotoAppShell(page); await openSettings(page); @@ -147,6 +217,42 @@ test.describe("Settings sidebar navigation", () => { test.describe("Settings — compact master-detail", () => { test.use({ viewport: { width: 390, height: 844 } }); + test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "settings-tab-switcher-", + title: "Compact tab switcher", + }); + try { + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + + const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }); + const newAgent = page.getByTestId("workspace-tab-switcher-new-agent"); + await switcher.click(); + await expect(newAgent).toBeInViewport(); + + // Navigate without dismissing the sheet through its backdrop first. + await page + .getByTestId("menu-button") + .filter({ visible: true }) + .first() + .dispatchEvent("click"); + await page.getByTestId("sidebar-settings").first().dispatchEvent("click"); + await expectAppRoute(page, buildSettingsRoute()); + await expect(newAgent).toHaveCount(0); + + await closeCompactSettings(page); + await expect(switcher).toBeVisible(); + await expect(newAgent).toHaveCount(0); + await switcher.click(); + await expect(newAgent).toBeInViewport(); + } finally { + await workspace.cleanup(); + } + }); + test("opens compact app and host details and returns through the settings list", async ({ page, }) => { diff --git a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts index 2c1fd0cc24..41da8ac3fa 100644 --- a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts @@ -10,6 +10,16 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page await page.keyboard.press(`${modifier}+Comma`); } +async function openComposerTab(page: import("@playwright/test").Page, agentId: string) { + await page + .getByTestId(`workspace-tab-agent_${agentId}`) + .filter({ visible: true }) + .first() + .click(); + await expectAgentTabActive(page, agentId); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); +} + interface ComposerFrame { at: number; visible: boolean; @@ -27,7 +37,9 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page) const sample = () => { if (capture.stopped) return; - const root = document.querySelector('[data-testid="message-input-root"]'); + const root = Array.from( + document.querySelectorAll('[data-testid="message-input-root"]'), + ).find((candidate) => candidate.offsetParent !== null); const input = root?.querySelector("textarea"); if (root && input) { const style = getComputedStyle(input); @@ -178,6 +190,8 @@ test.describe("Settings toggle tab regression", () => { await openWorkspaceWithAgents(page, [firstAgent, secondAgent]); await waitForTabBar(page); + await openComposerTab(page, firstAgent.id); + await openComposerTab(page, secondAgent.id); const input = page.getByRole("textbox", { name: "Message agent..." }); await input.fill("Keep this short draft in the composer"); const root = page.getByTestId("message-input-root").filter({ visible: true }).first(); @@ -194,6 +208,7 @@ test.describe("Settings toggle tab regression", () => { const frames = await stopComposerFrameCapture(page); const visibleFrames = frames.filter((frame) => frame.visible); + expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0); const tallestIndex = visibleFrames.reduce( (index, frame, candidateIndex) => frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index, diff --git a/packages/app/e2e/browser/sidebar-reorder.spec.ts b/packages/app/e2e/browser/sidebar-reorder.spec.ts index f6bf4d3f1b..03ffe9f7db 100644 --- a/packages/app/e2e/browser/sidebar-reorder.spec.ts +++ b/packages/app/e2e/browser/sidebar-reorder.spec.ts @@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi } }); -async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") { +async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") { await page.getByLabel("Display preferences", { exact: true }).click(); await page.getByRole("menuitem", { name: /^Sort/ }).click(); await page.getByRole("menuitem", { name, exact: true }).click(); @@ -362,3 +362,60 @@ test("compact groups retain active overflow and reveal the current ordered rows" await fixture.seed.cleanup(); } }); + +async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) { + await page.evaluate( + ({ projectKey: scope, workspaceKeys: order }) => { + localStorage.setItem( + "sidebar-project-workspace-order", + JSON.stringify({ + version: 1, + state: { + projectOrder: [scope], + pinnedWorkspaceOrder: [], + workspaceOrderByProject: { [scope]: order }, + }, + }), + ); + }, + { projectKey, workspaceKeys }, + ); + await reconnectSidebar(page); +} + +test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({ + page, +}) => { + const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" }); + try { + const second = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: "New manual workspace", + }); + if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed"); + const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`); + const ids = keys.map((key) => `sidebar-workspace-row-${key}`); + const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", ")); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys); + await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]); + await selectWorkspaceSort(page, "Manual"); + await expect.poll(() => rowTestIds(rows)).toEqual(ids); + await selectWorkspaceSort(page, "Created"); + await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]); + await selectWorkspaceSort(page, "Last prompt"); + await selectWorkspaceSort(page, "Manual"); + await expect.poll(() => rowTestIds(rows)).toEqual(ids); + await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow); + await reconnectSidebar(page); + await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]); + await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow); + await selectWorkspaceSort(page, "Created"); + await reconnectSidebar(page); + await selectWorkspaceSort(page, "Manual"); + await expect.poll(() => rowTestIds(rows)).toEqual(ids); + } finally { + await seed.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts index ba210131db..757b8908c2 100644 --- a/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts +++ b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts @@ -72,6 +72,9 @@ async function expectSortingRequiresHostUpdate(page: Page) { ).toBeDisabled(); await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0); await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0); + await page.getByRole("menuitem", { name: "Manual", exact: true }).click(); + await page.getByLabel("Display preferences", { exact: true }).click(); + await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual"); await page.keyboard.press("Escape"); } diff --git a/packages/app/e2e/mobile/sidebar-system-insets/android.mjs b/packages/app/e2e/mobile/sidebar-system-insets/android.mjs new file mode 100644 index 0000000000..cf7c45fbb0 --- /dev/null +++ b/packages/app/e2e/mobile/sidebar-system-insets/android.mjs @@ -0,0 +1,40 @@ +// Open a connected host with the wide sidebar visible, keyboard closed, and +// three-button navigation enabled on an Android tablet. Run against a fresh build: +// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; + +const [, , session, serial] = process.argv; +assert(session && serial, "Pass the agent-device session and adb serial"); +const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], { + encoding: "utf8", + timeout: 10_000, +}); +const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/); +assert(navBar, "A visible bottom navigation bar is required"); +assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first"); +const navigationTop = Number(navBar[1]); +const { data } = JSON.parse( + execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], { + encoding: "utf8", + timeout: 60_000, + }), +); +const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1]; +assert.equal( + focusedApp, + data.appBundleId, + "Snapshot must belong to the focused app on this device", +); +for (const id of [ + "sidebar-add-project", + "sidebar-hosts-trigger", + "sidebar-help", + "sidebar-settings", +]) { + const button = data.nodes.find((node) => node.identifier === id); + assert(button?.rect, `${id} must be visible above the system navigation bar`); + const bottom = button.rect.y + button.rect.height; + assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`); + console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`); +} diff --git a/packages/app/package.json b/packages/app/package.json index b59ef19b73..b0b806c7ab 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.13.0-beta.2", + "version": "0.13.0", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 3f5d218150..dbc501b5c7 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -719,8 +719,11 @@ function DesktopSidebar({ [active, resizeAnimatedStyle], ); const desktopSidebarBorderStyle = useMemo( - () => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }], - [insetsTop], + () => [ + styles.desktopSidebarBorder, + { flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom }, + ], + [insetsTop, insets.bottom], ); const sidebarHeaderGroupStyle = useMemo( () => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome], diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index cc248adeff..c3048e6d9a 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1550,6 +1550,11 @@ function ProjectBlock({ shortcutIndexByWorkspaceKey, onToggleCollapsed, onWorkspacePress, + parentGestureRef, + onWorkspaceReorder, + useNestable, + dragGestureHostActive, + manualSort, onWorktreeCreated, drag, isDragging, @@ -1571,6 +1576,11 @@ function ProjectBlock({ shortcutIndexByWorkspaceKey: Map; onToggleCollapsed: (projectViewKey: string) => void; onWorkspacePress?: () => void; + parentGestureRef?: MutableRefObject; + onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void; + useNestable: boolean; + dragGestureHostActive?: boolean; + manualSort: boolean; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; @@ -1624,7 +1634,14 @@ function ProjectBlock({ }); const renderWorkspaceRow = useCallback( - (item: SidebarWorkspacePlacement) => { + ( + item: SidebarWorkspacePlacement, + input?: { + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; + }, + ) => { return ( ); }, @@ -1658,6 +1678,29 @@ function ProjectBlock({ ], ); + const renderWorkspace = useCallback( + ({ + item, + drag: workspaceDrag, + isActive, + dragHandleProps: workspaceDragHandleProps, + }: DraggableRenderItemInfo) => { + return renderWorkspaceRow(item, { + drag: workspaceDrag, + isDragging: isActive, + dragHandleProps: workspaceDragHandleProps, + }); + }, + [renderWorkspaceRow], + ); + + const handleWorkspaceDragEnd = useCallback( + (workspaces: SidebarWorkspacePlacement[]) => { + onWorkspaceReorder(project.viewKey, workspaces); + }, + [onWorkspaceReorder, project.viewKey], + ); + const toast = useToast(); const { t } = useTranslation(); const [isRemovingProject, setIsRemovingProject] = useState(false); @@ -1723,12 +1766,29 @@ function ProjectBlock({ if (project.workspaces.length > 0) { projectChildren = ( <> - - {visibleWorkspaces.map(renderWorkspaceRow)} - + {manualSort ? ( + + ) : ( + + {visibleWorkspaces.map((item) => renderWorkspaceRow(item))} + + )} {canToggleWorkspaces ? ( state.togglePinnedCollapsed, ); + const manualSort = useSidebarViewStore((state) => state.workspaceSort === "manual"); + const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder); + const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder); const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder); const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); @@ -2211,6 +2279,30 @@ function ProjectModeList({ [getProjectOrder, setProjectOrder], ); + const handleWorkspaceReorder = useCallback( + (projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { + const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); + const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey); + if ( + !hasVisibleOrderChanged({ + currentOrder: currentWorkspaceOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }) + ) { + return; + } + + setWorkspaceOrder( + projectViewKey, + mergeWithRemainder({ + currentOrder: currentWorkspaceOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }), + ); + }, + [getWorkspaceOrder, setWorkspaceOrder], + ); + const handleWorktreeCreated = useCallback((workspaceId: string) => { setCreatingWorkspaceIds((current) => { const next = new Set(current); @@ -2259,6 +2351,11 @@ function ProjectModeList({ shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} onToggleCollapsed={onToggleProjectCollapsed} onWorkspacePress={onWorkspacePress} + manualSort={manualSort} + parentGestureRef={parentGestureRef} + onWorkspaceReorder={handleWorkspaceReorder} + useNestable={platformIsNative} + dragGestureHostActive={dragGestureHostActive} onWorktreeCreated={handleWorktreeCreated} drag={dragState.drag} isDragging={dragState.isDragging} @@ -2276,6 +2373,10 @@ function ProjectModeList({ collapsedProjectKeys, activeWorkspaceSelection, handleWorktreeCreated, + manualSort, + parentGestureRef, + dragGestureHostActive, + handleWorkspaceReorder, hostBadgeByServerId, supportsMultiplicityByServerId, supportsPinningByServerId, diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx index 4fb04ebb67..d6483a5519 100644 --- a/packages/app/src/components/sidebar/display-preferences/menu.tsx +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -244,10 +244,8 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { definitions.push({ id: "sort", title: t("sidebar.display.sort.label"), - content: workspaceSortSupported ? ( - - ) : ( - {t("sidebar.display.sort.updateHost")} + content: ( + ), }); } @@ -325,7 +323,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { setWorkspaceSort("manual"), [setWorkspaceSort]); const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]); const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]); return ( <> - - {t("sidebar.display.sort.created")} - - - {t("sidebar.display.sort.lastPrompt")} + + {t("sidebar.display.sort.manual")} + {automaticSortSupported ? ( + <> + + {t("sidebar.display.sort.created")} + + + {t("sidebar.display.sort.lastPrompt")} + + + ) : ( + {t("sidebar.display.sort.updateHost")} + )} ); } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 7401f7aac3..84d76c1083 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { GestureHandlerRootView } from "react-native-gesture-handler"; @@ -910,13 +911,9 @@ function resolveInitialActiveIndex( return selectedIndex >= 0 ? selectedIndex : fallbackIndex; } -type BottomSheetVisibility = ReturnType; - interface MobileBodyProps { - bottomSheetRef: BottomSheetVisibility["sheetRef"]; + isOpen: boolean; snapPoints: string[]; - handleSheetChange: BottomSheetVisibility["handleSheetChange"]; - handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"]; handleIndicatorStyle: { backgroundColor: string }; titleColor: string; title: string; @@ -944,7 +941,22 @@ interface MobileBodyProps { safeAreaBottom: number; } -function MobileComboboxBody(props: MobileBodyProps): ReactElement { +function MobileComboboxBody(props: MobileBodyProps): ReactElement | null { + const active = useRetainedPanelActive(); + if (!active) return null; + // The visibility tracker must leave the tree with its sheet. + return ; +} + +function MobileComboboxSheet(props: MobileBodyProps): ReactElement { + const { + sheetRef: bottomSheetRef, + handleSheetChange, + handleSheetDismiss, + } = useIsolatedBottomSheetVisibility({ + visible: props.isOpen, + onClose: props.onClose, + }); const comboboxTitleStyle = useMemo( () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], @@ -979,13 +991,13 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { return ( { @@ -1190,6 +1203,9 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { [props.refs, setWebOverlayScope], ); + // Remove the portal before a retained chat can freeze its closing update. + if (!active) return null; + const overlay = ( { + if (!active && isOpen) handleClose(); + }, [active, isOpen, handleClose]); + useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey); const collisionPadding = useMemo(computeCollisionPadding, []); @@ -1413,16 +1434,6 @@ export function Combobox({ referenceWidth, }); - const { - sheetRef: bottomSheetRef, - handleSheetChange, - handleSheetDismiss, - } = useIsolatedBottomSheetVisibility({ - visible: isOpen, - isEnabled: isMobile, - onClose: handleClose, - }); - const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ""; const sanitizedSearchValue = searchQuery.trim(); const showCustomOption = useMemo( @@ -1572,10 +1583,8 @@ export function Combobox({ if (isMobile) { return ( { persistedProjectOrder: ["stale-project"], }); - expect(updates).toEqual({ projectOrder: null }); + expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] }); }); it("appends unseen projects without changing saved project order", () => { @@ -979,3 +979,20 @@ it("retains unrelated projects and keyed row placements when one project reorder expect(after.projects[0].workspaces[0]).toBe(before.projects[0].workspaces[1]); expect(after.projects[0].workspaces[1]).toBe(before.projects[0].workspaces[0]); }); + +it("prepends newly discovered manual workspaces without dropping filtered saved keys", () => { + const projects = [sidebarProject({ projectKey: "p", workspaceKeys: ["new", "b", "a"] })]; + expect( + computeSidebarOrderUpdates({ + projects, + persistedProjectOrder: ["p"], + getWorkspaceOrder: () => ["srv:hidden", "srv:a", "srv:b"], + }), + ).toEqual({ + projectOrder: null, + workspaceOrders: [{ projectViewKey: "p", order: ["srv:new", "srv:hidden", "srv:a", "srv:b"] }], + }); + expect( + computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["p"] }).workspaceOrders, + ).toEqual([]); +}); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 3c4ce4e2da..da1086da45 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -535,21 +535,57 @@ export function appendMissingOrderKeys(input: { return [...input.currentOrder, ...missingKeys]; } +export function prependMissingOrderKeys(input: { + currentOrder: string[]; + visibleKeys: string[]; +}): string[] { + if (input.visibleKeys.length === 0) { + return input.currentOrder; + } + + const existingKeys = new Set(input.currentOrder); + const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); + if (missingKeys.length === 0) { + return input.currentOrder; + } + + return [...missingKeys, ...input.currentOrder]; +} + export interface SidebarOrderUpdates { projectOrder: string[] | null; + workspaceOrders: Array<{ projectViewKey: string; order: string[] }>; } export function computeSidebarOrderUpdates(input: { projects: SidebarProjectEntry[]; persistedProjectOrder: string[]; + getWorkspaceOrder?: (projectViewKey: string) => string[]; }): SidebarOrderUpdates { + if (input.projects.length === 0) { + return { projectOrder: null, workspaceOrders: [] }; + } + const nextProjectOrder = appendMissingOrderKeys({ currentOrder: input.persistedProjectOrder, visibleKeys: input.projects.map((project) => project.viewKey), }); - return { - projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder, - }; + const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder; + + const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = []; + for (const project of input.projects) { + if (!input.getWorkspaceOrder) break; + const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey); + const nextWorkspaceOrder = prependMissingOrderKeys({ + currentOrder: persistedWorkspaceOrder, + visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), + }); + if (nextWorkspaceOrder !== persistedWorkspaceOrder) { + workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder }); + } + } + + return { projectOrder, workspaceOrders }; } export interface SidebarLoadingState { diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index c5fd59d53e..a30b27ce8f 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -61,8 +61,8 @@ describe("ACP provider catalog", () => { it("offers MiniMax Code through its pinned public ACP package", () => { expect(findProvider("minimax-code")).toMatchObject({ title: "MiniMax Code", - version: "0.1.2", - command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"], + version: "0.6.13", + command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"], }); expect(findProvider("minimax-code").iconSvg).toContain(" state.groupMode); const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes"); const workspaceSortSupported = [...sortSupport.values()].every(Boolean); - const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined; + const sort = + groupMode === "project" && (workspaceSort === "manual" || workspaceSortSupported) + ? workspaceSort + : undefined; const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort); const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []); @@ -166,12 +169,16 @@ export function useSidebarWorkspacesList(options?: { const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder, + getWorkspaceOrder: sort === "manual" ? orderStore.getWorkspaceOrder : undefined, }); if (updates.projectOrder) { orderStore.setProjectOrder(updates.projectOrder); } - }, [persistedProjectOrder, projects]); + for (const update of updates.workspaceOrders) { + orderStore.setWorkspaceOrder(update.projectViewKey, update.order); + } + }, [persistedProjectOrder, projects, sort]); const refreshAll = useCallback(() => { if (!isActive) return; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 16779d95e1..c765f3b835 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1169,6 +1169,7 @@ export const ar: TranslationResources = { sort: { label: "الترتيب", created: "تاريخ الإنشاء", + manual: "يدوي", lastPrompt: "آخر طلب", updateHost: "حدّث المضيفين لترتيب مساحات العمل", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 6471b03f60..351fb33ac3 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1177,6 +1177,7 @@ export const en = { sort: { label: "Sort", created: "Created", + manual: "Manual", lastPrompt: "Last prompt", updateHost: "Update hosts to sort workspaces", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 82ab9e6977..fd195ce599 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1205,6 +1205,7 @@ export const es: TranslationResources = { sort: { label: "Orden", created: "Creación", + manual: "Manual", lastPrompt: "Último prompt", updateHost: "Actualiza los hosts para ordenar los espacios de trabajo", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 44aae87628..975d6a3736 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1198,6 +1198,7 @@ export const fr: TranslationResources = { sort: { label: "Tri", created: "Création", + manual: "Manuel", lastPrompt: "Dernier prompt", updateHost: "Mettez les hôtes à jour pour trier les espaces de travail", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index baa248aa31..60a207e1e1 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1182,6 +1182,7 @@ export const ja: TranslationResources = { sort: { label: "並び順", created: "作成日時", + manual: "手動", lastPrompt: "最後のプロンプト", updateHost: "ワークスペースを並べ替えるにはホストを更新してください", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index a573ef1ffa..4773625327 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1176,6 +1176,7 @@ export const ko: TranslationResources = { sort: { label: "정렬", created: "생성일", + manual: "수동", lastPrompt: "마지막 프롬프트", updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 43c32d2914..766c6d98ed 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1195,6 +1195,7 @@ export const ptBR: TranslationResources = { sort: { label: "Ordenação", created: "Criação", + manual: "Manual", lastPrompt: "Último prompt", updateHost: "Atualize os hosts para ordenar os espaços de trabalho", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 586ad8595d..23f3d810ee 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1186,6 +1186,7 @@ export const ru: TranslationResources = { sort: { label: "Сортировка", created: "Дата создания", + manual: "Вручную", lastPrompt: "Последний запрос", updateHost: "Обновите хосты для сортировки рабочих пространств", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 08bb8491cd..43d8ceeef2 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1161,6 +1161,7 @@ export const zhCN: TranslationResources = { sort: { label: "排序", created: "创建时间", + manual: "手动", lastPrompt: "最近提示词", updateHost: "请更新主机以排序工作区", }, diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index c1f95b87fa..1aae11f63d 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { useRevealedText } from "@/hooks/use-revealed-text"; import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate"; const runtime = { @@ -705,6 +706,21 @@ describe("evaluatePluginClientBundle", () => { expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]); }); + it("keeps the host reveal hook on the deprecated client/react-native entry", () => { + const plugin = evaluatePluginClientBundle( + "legacy-reveal", + bundle(` + const legacy = require("@getpaseo/plugin/client/react-native"); + const ui = require("@getpaseo/plugin/client/ui"); + if (typeof legacy.useRevealedText !== "function") throw new Error("Missing legacy reveal hook"); + if (legacy.StreamedText !== ui.StreamedText) throw new Error("Missing current UI exports"); + if ("useRevealedText" in ui) throw new Error("Legacy hook leaked into current UI"); + plugin.addScreen({ id: "reveal", title: "Reveal", Component: legacy.useRevealedText }); + `), + ); + expect(plugin.surfaces[0]?.Component).toBe(useRevealedText); + }); + it("keeps shared and client runtime exports separate", () => { expect(() => evaluatePluginClientBundle( diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 44bcbec57d..ea37209f57 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -2,6 +2,7 @@ import { requirePluginText } from "@getpaseo/plugin/client/host"; import type { createPluginHosts } from "./hosts"; import { openExternalUrl } from "@/utils/open-external-url"; import * as pluginUiRuntime from "./react-native/ui"; +import { useRevealedText } from "@/hooks/use-revealed-text"; import { useSettings } from "./settings/use-settings"; import * as pluginSharedRuntime from "@getpaseo/plugin"; import * as pluginClientRuntime from "@getpaseo/plugin/client"; @@ -114,6 +115,10 @@ export type PluginClientRuntime = Pick< | "addHeaderButton" > & { hosts: ReturnType }; +// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20 +// The 0.12 entry exposed the reveal hook as well as UI components. +const pluginReactNativeCompatRuntime = { ...pluginUiRuntime, useRevealedText }; + export function runPluginClientBundle( id: string, bundle: string, @@ -503,7 +508,7 @@ export function runPluginClientBundle( }; const runtimeRequire = (name: string): unknown => { // COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20 - if (name === "@getpaseo/plugin/client/react-native") return pluginUiRuntime; + if (name === "@getpaseo/plugin/client/react-native") return pluginReactNativeCompatRuntime; if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime; if (name === "react") return React; if (name === "react/jsx-runtime") return ReactJsxRuntime; diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index 92e6c68de4..0dac64b371 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -30,7 +30,7 @@ interface WorkspaceStructureSession { workspaces: Iterable; } -export type WorkspaceSort = "created" | "lastPrompt"; +export type WorkspaceSort = "manual" | "created" | "lastPrompt"; interface WorkspaceSortItem { workspaceName: string; diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts index c9a674f3b3..792c005100 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -266,3 +266,115 @@ it("setup metadata from directory snapshots and transitions resets failure surfa expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(false); expect(useWorkspaceSetupStore.getState().snapshots).toEqual({}); }); + +it("keeps a live workspace update when an older creation snapshot arrives", () => { + const serverId = "workspace-create-after-pin"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const initial = workspace("new"); + const pinned = { + ...initial, + pinnedAt: "2026-09-26T00:00:00.000Z", + title: "Updated title", + }; + + try { + replica.applyDelta({ kind: "upsert", workspace: pinned }); + const accepted = useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id); + + expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(initial)])).toEqual([]); + expect(replica.snapshot().workspaces.get(initial.id)).toEqual( + normalizeWorkspaceDescriptor(pinned), + ); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toBe( + accepted, + ); + } finally { + store.clearSession(serverId); + } +}); + +it("seeds missing workspaces before live updates and in a mixed creation batch", () => { + const serverId = "workspace-create-before-pin"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const initial = normalizeWorkspaceDescriptor(workspace("new")); + const second = normalizeWorkspaceDescriptor(workspace("second")); + const pinned = { ...workspace(initial.id), pinnedAt: "2026-09-26T00:00:00.000Z" }; + + try { + expect(replica.acceptWorkspaces([initial])).toEqual([ + { kind: "workspace", type: "upsert", id: initial.id, value: initial }, + ]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual( + initial, + ); + + replica.applyDelta({ kind: "upsert", workspace: pinned }); + expect(replica.acceptWorkspaces([initial, second])).toEqual([ + { kind: "workspace", type: "upsert", id: second.id, value: second }, + ]); + expect([...useSessionStore.getState().sessions[serverId]!.workspaces.values()]).toEqual([ + normalizeWorkspaceDescriptor(pinned), + second, + ]); + } finally { + store.clearSession(serverId); + } +}); + +it("keeps a manual unpin when a stale pinned snapshot arrives", () => { + const serverId = "workspace-manual-unpin"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const pinned = { ...workspace("new"), pinnedAt: "2026-09-26T00:00:00.000Z" }; + const unpinned = { ...pinned, pinnedAt: null }; + + try { + replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)]); + replica.applyDelta({ kind: "upsert", workspace: unpinned }); + expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)])).toEqual([]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(pinned.id)).toEqual( + normalizeWorkspaceDescriptor(unpinned), + ); + } finally { + store.clearSession(serverId); + } +}); + +it("restores a missing workspace after an archive failure without replacing a live row", () => { + const serverId = "workspace-archive-rollback"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const initial = normalizeWorkspaceDescriptor(workspace("new")); + + try { + replica.acceptWorkspaces([initial]); + markWorkspaceArchivePending({ serverId, workspaceId: initial.id }); + replica.removeWorkspaceSnapshot(initial.id); + expect(replica.acceptWorkspaces([initial])).toEqual([]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(initial.id)).toBe(false); + + clearWorkspaceArchivePending({ serverId, workspaceId: initial.id }); + expect(replica.acceptWorkspaces([initial])).toEqual([ + { kind: "workspace", type: "upsert", id: initial.id, value: initial }, + ]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual( + initial, + ); + + const updated = { ...workspace(initial.id), title: "Updated during archive" }; + replica.applyDelta({ kind: "upsert", workspace: updated }); + expect(replica.acceptWorkspaces([initial])).toEqual([]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual( + normalizeWorkspaceDescriptor(updated), + ); + } finally { + clearWorkspaceArchivePending({ serverId, workspaceId: initial.id }); + store.clearSession(serverId); + } +}); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index 2eb107ac4f..8f21ebd214 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -82,7 +82,12 @@ export class WorkspaceDirectoryReplica { } acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): DirectoryReplicaMutation[] { - return workspaces.flatMap((workspace) => this.upsertWorkspace(workspace)); + // Creation responses and progress snapshots can arrive after live updates. + // They seed missing rows; the directory owns rows it has already received. + return workspaces.flatMap((workspace) => { + if (this.workspaces.has(workspace.id)) return []; + return this.upsertWorkspace(workspace); + }); } acceptProject(project: ProjectDescriptor): DirectoryReplicaMutation[] { diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index 48c5594f21..3dba13bc80 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -117,13 +117,17 @@ export function useWorkspaceStructure( (state) => selectProjectOrder(state), workspaceEqualityFns.deep, ); + const workspaceOrderByProject = useSidebarOrderStore((state) => + workspaceSort === "manual" ? state.workspaceOrderByProject : undefined, + ); return useMemo( () => composeWorkspaceStructure({ projects, projectOrder, + workspaceOrderByProject, }), - [projectOrder, projects], + [projectOrder, projects, workspaceOrderByProject], ); } diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index a9c9d07772..95cc1ba498 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -640,3 +640,27 @@ it("a prompt changes only its project's structure and output preserves both proj expect(output[0]).toBe(after[0]); expect(output[1]).toBe(after[1]); }); + +it("applies manual order only when requested without mutating the automatic baseline", () => { + const project = { + viewKey: "p", + projectKey: "p", + projectName: "P", + projectKind: "git" as const, + iconWorkingDir: "/repo", + hosts: [], + workspaceKeys: ["h:new", "h:b", "h:a"], + }; + const input = { projects: [project], projectOrder: ["p"] }; + expect( + composeWorkspaceStructure({ + ...input, + workspaceOrderByProject: { p: ["h:filtered", "h:a", "h:b"] }, + }).projects[0].workspaceKeys, + ).toEqual(["h:new", "h:a", "h:b"]); + expect(composeWorkspaceStructure(input).projects[0].workspaceKeys).toEqual([ + "h:new", + "h:b", + "h:a", + ]); +}); diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index 6c1100edd6..c131fa7a3a 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -254,6 +254,7 @@ export function selectProjectOrder(state: SidebarOrderSnapshot): string[] { export function composeWorkspaceStructure(input: { projects: WorkspaceStructureProject[]; projectOrder: readonly string[]; + workspaceOrderByProject?: Readonly>; }): WorkspaceStructure { if (input.projects.length === 0) { return EMPTY_WORKSPACE_STRUCTURE; @@ -265,7 +266,21 @@ export function composeWorkspaceStructure(input: { getKey: (project) => project.viewKey, }); - return { projects: orderedProjects }; + return { + // Keep the shared automatic projection immutable when applying a sidebar preference. + // eslint-disable-next-line oxc/no-map-spread + projects: orderedProjects.map((project) => { + const storedOrder = input.workspaceOrderByProject?.[project.viewKey]; + if (!storedOrder) return project; + const orderedProject = { ...project }; + orderedProject.workspaceKeys = applyStoredOrdering({ + items: project.workspaceKeys, + storedOrder, + getKey: (key) => key, + }); + return orderedProject; + }), + }; } export function selectWorkspaceKeys(state: SessionsSnapshot, serverId: string | null): string[] { diff --git a/packages/app/src/stores/sidebar-order-store.test.ts b/packages/app/src/stores/sidebar-order-store.test.ts index b34fb02240..b288089742 100644 --- a/packages/app/src/stores/sidebar-order-store.test.ts +++ b/packages/app/src/stores/sidebar-order-store.test.ts @@ -6,7 +6,7 @@ import { import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store"; describe("migrateSidebarOrderState", () => { - it("discards legacy workspace ordering while retaining project ordering", () => { + it("preserves legacy workspace ordering across hosts", () => { const migrated = migrateSidebarOrderState({ projectOrderByServerId: { "host-a": ["project-a"], @@ -21,6 +21,7 @@ describe("migrateSidebarOrderState", () => { expect(migrated).toEqual({ projectOrder: ["project-a"], pinnedWorkspaceOrder: [], + workspaceOrderByProject: { "project-a": ["host-a:main", "host-a:feature", "host-b:main"] }, }); }); @@ -88,14 +89,18 @@ describe("sidebar order keys that end in whitespace", () => { }); }); -it("preserves project and pinned order while discarding ordinary workspace order", () => { +it("preserves project, pinned and ordinary workspace order", () => { expect( migrateSidebarOrderState({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"], workspaceOrderByProject: { a: ["h:old", "h:new"] }, }), - ).toEqual({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"] }); + ).toEqual({ + projectOrder: ["b", "a"], + pinnedWorkspaceOrder: ["h:y", "h:x"], + workspaceOrderByProject: { a: ["h:old", "h:new"] }, + }); }); it("preserves whitespace in current project and pin identities during upgrade", () => { @@ -105,5 +110,37 @@ it("preserves whitespace in current project and pin identities during upgrade", pinnedWorkspaceOrder: ["host:workspace "], workspaceOrderByProject: {}, }), - ).toEqual({ projectOrder: ["project "], pinnedWorkspaceOrder: ["host:workspace "] }); + ).toEqual({ + projectOrder: ["project "], + pinnedWorkspaceOrder: ["host:workspace "], + workspaceOrderByProject: {}, + }); +}); + +it("retains a pre-beta manual order when upgrading", () => { + expect( + migrateSidebarOrderState({ + projectOrder: ["project"], + pinnedWorkspaceOrder: [], + workspaceOrderByProject: { project: ["host:b", "host:a"] }, + }), + ).toEqual({ + projectOrder: ["project"], + pinnedWorkspaceOrder: [], + workspaceOrderByProject: { project: ["host:b", "host:a"] }, + }); +}); + +it("starts with an empty manual order for beta users whose order was discarded", () => { + expect(migrateSidebarOrderState({ projectOrder: ["p"], pinnedWorkspaceOrder: ["h:a"] })).toEqual({ + projectOrder: ["p"], + pinnedWorkspaceOrder: ["h:a"], + workspaceOrderByProject: {}, + }); +}); +it("preserves spaces in saved workspace scopes and identities", () => { + expect( + migrateSidebarOrderState({ workspaceOrderByProject: { "project ": ["host:b ", "host:a "] } }) + .workspaceOrderByProject, + ).toEqual({ "project ": ["host:b ", "host:a "] }); }); diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 54f27e714f..44e3887620 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -7,10 +7,13 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora interface SidebarOrderStoreState { projectOrder: string[]; pinnedWorkspaceOrder: string[]; + workspaceOrderByProject: Record; getProjectOrder: () => string[]; setProjectOrder: (keys: string[]) => void; getPinnedWorkspaceOrder: () => string[]; setPinnedWorkspaceOrder: (keys: string[]) => void; + getWorkspaceOrder: (projectViewKey: string) => string[]; + setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void; } interface SidebarOrderPersistedState { @@ -25,12 +28,16 @@ const StringArrayRecordSchema = z.record(z.string(), z.array(z.string())); const SidebarOrderPersistedStateSchema = z.strictObject({ projectOrder: z.array(z.string()).optional(), pinnedWorkspaceOrder: z.array(z.string()).optional(), - // COMPAT(automaticWorkspaceOrder): v0.13.0; accept discarded keys until 2027-04-22 so strict parsing preserves projects/pins. workspaceOrderByProject: StringArrayRecordSchema.optional(), projectOrderByServerId: StringArrayRecordSchema.optional(), workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(), }); +interface SidebarWorkspaceOrderScope { + serverId: string; + projectViewKey: string; +} + // Trims each key. Only for persisted state read at migration time, where a // stray space is an artifact of an older format rather than part of the key. function normalizeKeys(keys: string[]): string[] { @@ -71,13 +78,42 @@ function dedupeKeys(keys: string[]): string[] { return deduped; } +function normalizeWorkspaceOrderByProject( + workspaceOrderByProject: Record | undefined, +): Record { + const normalized: Record = {}; + for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) { + const scope = projectViewKey; + if (!scope.trim()) continue; + normalized[scope] = dedupeKeys(order); + } + return normalized; +} + +function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScope | null { + const separatorIndex = scopeKey.indexOf("::"); + if (separatorIndex < 0) return null; + const serverId = scopeKey.slice(0, separatorIndex).trim(); + const projectViewKey = scopeKey.slice(separatorIndex + 2).trim(); + if (!serverId || !projectViewKey) return null; + return { serverId, projectViewKey }; +} + +function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null { + const workspaceKey = rawWorkspaceKey.trim(); + if (!workspaceKey) return null; + const serverPrefix = `${serverId}:`; + return workspaceKey.startsWith(serverPrefix) ? workspaceKey : `${serverPrefix}${workspaceKey}`; +} + export function migrateSidebarOrderState(persistedState: unknown): { projectOrder: string[]; pinnedWorkspaceOrder: string[]; + workspaceOrderByProject: Record; } { const result = SidebarOrderPersistedStateSchema.safeParse(persistedState); if (!result.success) { - return { projectOrder: [], pinnedWorkspaceOrder: [] }; + return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} }; } const state: SidebarOrderPersistedState = result.data; @@ -91,9 +127,26 @@ export function migrateSidebarOrderState(persistedState: unknown): { } } + const workspaceOrderByProject = normalizeWorkspaceOrderByProject(state.workspaceOrderByProject); + for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) { + const scope = extractWorkspaceOrderScope(scopeKey); + if (!scope) continue; + const existing = workspaceOrderByProject[scope.projectViewKey] ?? []; + const merged = [...existing]; + const seen = new Set(merged); + for (const key of order) { + const workspaceKey = normalizeLegacyWorkspaceKey(scope.serverId, key); + if (!workspaceKey || seen.has(workspaceKey)) continue; + seen.add(workspaceKey); + merged.push(workspaceKey); + } + workspaceOrderByProject[scope.projectViewKey] = merged; + } + return { projectOrder, pinnedWorkspaceOrder: dedupeKeys(state.pinnedWorkspaceOrder ?? []), + workspaceOrderByProject, }; } @@ -102,6 +155,7 @@ export const useSidebarOrderStore = create()( (set, get) => ({ projectOrder: [], pinnedWorkspaceOrder: [], + workspaceOrderByProject: {}, getProjectOrder: () => get().projectOrder, setProjectOrder: (keys) => { set({ projectOrder: dedupeKeys(keys) }); @@ -110,6 +164,19 @@ export const useSidebarOrderStore = create()( setPinnedWorkspaceOrder: (keys) => { set({ pinnedWorkspaceOrder: dedupeKeys(keys) }); }, + getWorkspaceOrder: (projectViewKey) => { + if (!projectViewKey.trim()) return []; + return get().workspaceOrderByProject[projectViewKey] ?? []; + }, + setWorkspaceOrder: (projectViewKey, keys) => { + if (!projectViewKey.trim()) return; + set((state) => ({ + workspaceOrderByProject: { + ...state.workspaceOrderByProject, + [projectViewKey]: dedupeKeys(keys), + }, + })); + }, }), { name: "sidebar-project-workspace-order", @@ -117,8 +184,9 @@ export const useSidebarOrderStore = create()( partialize: (state) => ({ projectOrder: state.projectOrder, pinnedWorkspaceOrder: state.pinnedWorkspaceOrder, + workspaceOrderByProject: state.workspaceOrderByProject, }), - version: 2, + version: 3, migrate: migrateSidebarOrderState, }, ), diff --git a/packages/app/src/stores/sidebar-view-store.test.ts b/packages/app/src/stores/sidebar-view-store.test.ts index a9e44e146b..4a277ae60d 100644 --- a/packages/app/src/stores/sidebar-view-store.test.ts +++ b/packages/app/src/stores/sidebar-view-store.test.ts @@ -300,3 +300,7 @@ it("defaults to Created and persists the Last prompt choice across migration", ( migrateSidebarViewState({ workspaceSort: "lastPrompt", groupMode: "status" }), ).toMatchObject({ workspaceSort: "lastPrompt", groupMode: "status" }); }); + +it("retains the manual sorting preference on reload", () => { + expect(migrateSidebarViewState({ workspaceSort: "manual" }).workspaceSort).toBe("manual"); +}); diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index 229e95f96b..1b53fdfe9d 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -91,7 +91,7 @@ const SidebarLabelFilterSchema = z.object({ labels: z.array(z.string()), }); const SidebarViewPersistedStateSchema = z.strictObject({ - workspaceSort: z.enum(["created", "lastPrompt"]).optional(), + workspaceSort: z.enum(["manual", "created", "lastPrompt"]).optional(), showBackground: z.boolean().optional(), groupMode: PersistedSidebarGroupModeSchema.optional(), hostFilters: z.array(z.string()).optional(), diff --git a/packages/cli/package.json b/packages/cli/package.json index 8efbc81267..dd4ee4686d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.13.0-beta.2", + "version": "0.13.0", "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.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", - "@getpaseo/server": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/protocol": "0.13.0", + "@getpaseo/server": "0.13.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index b0d7961ace..64c559cdab 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.13.0-beta.2", + "version": "0.13.0", "description": "Paseo client SDK package", "files": [ "dist", @@ -47,8 +47,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.13.0-beta.2", - "@getpaseo/relay": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0", + "@getpaseo/relay": "0.13.0", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 8da5decfa4..b96b9f88e6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.13.0-beta.2", + "version": "0.13.0", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 5c265cc23c..b2da02d6e0 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.0-beta.2", + "version": "0.13.0", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index b7c478bce3..c1dd7c9381 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.13.0-beta.2", + "version": "0.13.0", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 17f9f76bd5..b6967d6fc1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.13.0-beta.2", + "version": "0.13.0", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -72,8 +72,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/protocol": "0.13.0", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -83,8 +83,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/protocol": "0.13.0", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index e2547154bc..c9b54ecdb7 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -101,12 +101,29 @@ describe("plugin SDK import boundaries", () => { ); }); - it.each(compatibilityEntries)("%s only re-exports the UI entry", (entry) => { - const source = readFileSync( - path.resolve(sourceDirectory, "..", manifest.exports[entry].source), - "utf8", - ); - expect(source.replace(/\/\/[^\n]*|\/\*[^]*?\*\//g, "").trim()).toBe('export * from "./ui.js";'); + it.each(compatibilityEntries)("%s respects the client boundary", (entry) => { + expect( + boundaryViolations( + path.resolve(sourceDirectory, "..", manifest.exports[entry].source), + "client", + ), + ).toEqual([]); + }); + + it("typechecks a 0.12 reveal-hook consumer without exposing the hook on current UI", () => { + expect( + typeErrors(` + import { useRevealedText, Icon } from "@getpaseo/plugin/client/react-native"; + import { StreamedText } from "@getpaseo/plugin/client/ui"; + // @ts-expect-error the legacy hook is not part of the current UI API + import { useRevealedText as currentHook } from "@getpaseo/plugin/client/ui"; + import { Text } from "react-native"; + function LegacyRow({ text, phase }: { text: string; phase: "streaming" | "complete" }) { + const revealed: string = useRevealedText(text, phase); + return {revealed}; + } + `), + ).toEqual([]); }); it.each(Object.entries(entries))( diff --git a/packages/plugin/src/client/react-native.ts b/packages/plugin/src/client/react-native.ts index e1eb92f17b..13e8f88370 100644 --- a/packages/plugin/src/client/react-native.ts +++ b/packages/plugin/src/client/react-native.ts @@ -1,3 +1,6 @@ // COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20 /** @deprecated Import Paseo UI from @getpaseo/plugin/client/ui. */ export * from "./ui.js"; + +/** @deprecated Render StreamedText from @getpaseo/plugin/client/ui. Removed after 2027-04-20 with this entry. */ +export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string; diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index 05403594cf..b2fbee8e2c 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -374,13 +374,19 @@ class AcpRuntime { /** * Fallback ids for streamed chunks that carry no `messageId`, one per * chunk kind. Consecutive chunks of a kind continue the same timeline - * item; a chunk of the other kind, a tool call, a plan, a user message, - * or a turn boundary ends it (#4699). + * item; a chunk of another kind, a tool call, a plan, or a turn boundary + * ends it (#4699). */ private readonly fallbackChunkIds = new Map< - "agent_message_chunk" | "agent_thought_chunk", + "agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk", string >(); + /** + * Active while a persisted session loads. ACP agents replay the loaded + * session's updates before session/load resolves; the value follows the + * open request's history contract. + */ + private replayWindow: "replay" | "skip" | null = null; private closing = false; private processFailed = false; private configTransaction = false; @@ -499,15 +505,23 @@ class AcpRuntime { }; let response: Pick; if (nativeSessionId) { - response = await this.call( - this.connection.loadSession({ - sessionId: nativeSessionId, - cwd: input.config.cwd, - mcpServers, - _meta: metadata, - }), - ); + // ACP agents replay the loaded session's updates before session/load resolves, + // so the native id must be registered first for them to pass the session filter. this.nativeSessionId = nativeSessionId; + this.replayWindow = input.history === "replay" ? "replay" : "skip"; + try { + response = await this.call( + this.connection.loadSession({ + sessionId: nativeSessionId, + cwd: input.config.cwd, + mcpServers, + _meta: metadata, + }), + ); + await this.drainNotifications(); + } finally { + this.replayWindow = null; + } } else { const newSession = await this.call( this.connection.newSession({ @@ -898,6 +912,14 @@ class AcpRuntime { private sessionUpdate(notification: SessionNotification): void { if (notification.sessionId !== this.nativeSessionId) return; + if (this.replayWindow === "skip") return; + if ( + this.replayWindow === "replay" && + notification.update.sessionUpdate === "user_message_chunk" + ) { + this.reduceUserMessageChunk(notification.update); + return; + } this.reduceUpdate(notification.update); } @@ -911,13 +933,14 @@ class AcpRuntime { this.fallbackChunkIds.clear(); return; } - // A chunk of one kind ends the other kind's item, whatever its content - // or id: reasoning, then text, then reasoning again are three items. + // A chunk of one kind ends every other kind's open item, whatever its + // content or id: reasoning, then text, then reasoning again are three items. this.fallbackChunkIds.delete( update.sessionUpdate === "agent_message_chunk" ? "agent_thought_chunk" : "agent_message_chunk", ); + this.fallbackChunkIds.delete("user_message_chunk"); if (update.content.type !== "text") return; const id = this.resolveChunkId(update.sessionUpdate, update.messageId); const text = `${this.messages.get(id) ?? ""}${update.content.text}`; @@ -945,8 +968,31 @@ class AcpRuntime { this.reduceStateUpdate(update); } + // Historical user messages arrive only as replayed chunks; a live prompt + // already echoes its own user message when it is admitted. + private reduceUserMessageChunk( + update: Extract, + ): void { + this.fallbackChunkIds.delete("agent_message_chunk"); + this.fallbackChunkIds.delete("agent_thought_chunk"); + if (update.content.type !== "text") return; + const id = this.resolveChunkId("user_message_chunk", update.messageId); + const text = `${this.messages.get(id) ?? ""}${update.content.text}`; + this.messages.set(id, text); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: "user_message", + id, + text, + ...(update.messageId ? { messageId: update.messageId } : {}), + }, + }); + } + private resolveChunkId( - kind: "agent_message_chunk" | "agent_thought_chunk", + kind: "agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk", messageId: string | null | undefined, ): string { if (messageId) { diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index 40c8019439..f4b429578f 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -1500,3 +1500,187 @@ describe("ACP streamed message boundaries", () => { }, ); }); + +function userChunk(text: string, messageId?: string): SessionUpdate { + return { + sessionUpdate: "user_message_chunk", + content: { type: "text", text }, + ...(messageId === undefined ? {} : { messageId }), + }; +} + +async function openReplaySession( + replay: SessionUpdate[], + history: "replay" | "skip", +): Promise<{ connection: ProviderConnection; events: ProviderEvent[] }> { + const harness = connectorHarness({ + capabilities: { loadSession: true }, + handleRequest(instance, request) { + if (request.method !== "session/load") return false; + for (const update of replay) { + instance.notify("session/update", { sessionId: "connector-session", update }); + } + instance.respond(request, { + sessionId: "connector-session", + modes: null, + configOptions: [], + }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "replay-acp", + label: "Replay ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "session.persistence"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + const input = openInput(); + input.persistence = { version: 1, data: { sessionId: "connector-session" } }; + input.history = history; + await connection.send(input); + await waitForEvent(events, (event) => event.type === "session.ready"); + return { connection, events }; +} + +function timelineProjection(events: ProviderEvent[]): Array> { + return events.flatMap((event) => { + if (event.type !== "timeline.item") return []; + const item = event.item; + if ( + item.type === "user_message" || + item.type === "assistant_message" || + item.type === "reasoning" + ) { + return [{ type: item.type, id: item.id, text: item.text }]; + } + if (item.type === "tool_call") return [{ type: item.type, id: item.id, status: item.status }]; + return [{ type: item.type, id: item.id }]; + }); +} + +describe("ACP session history replay", () => { + it("restores the timeline replayed before session/load resolves", async () => { + const { connection, events } = await openReplaySession( + [ + userChunk("What is the ", "user-1"), + userChunk("temperature?", "user-1"), + textChunk("Checking the sensor", "agent_thought_chunk"), + { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Read sensor", + kind: "read", + status: "in_progress", + }, + { sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: "completed" }, + textChunk("It is 25°C"), + ], + "replay", + ); + try { + expect(timelineProjection(events)).toEqual([ + { type: "user_message", id: "user-1", text: "What is the " }, + { type: "user_message", id: "user-1", text: "What is the temperature?" }, + { type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" }, + { type: "tool_call", id: "tool-1", status: "running" }, + { type: "tool_call", id: "tool-1", status: "completed" }, + { type: "assistant_message", id: "agent_message_chunk:2", text: "It is 25°C" }, + ]); + const lastReplayed = events.findLastIndex((event) => event.type === "timeline.item"); + const opened = events.findIndex((event) => event.type === "session.opened"); + expect(lastReplayed).toBeLessThan(opened); + } finally { + await connection.close(); + } + }); + + it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => { + const { connection, events } = await openReplaySession( + [ + userChunk("first "), + userChunk("question"), + textChunk("answer"), + userChunk("second question"), + ], + "replay", + ); + try { + expect(timelineProjection(events)).toEqual([ + { type: "user_message", id: "user_message_chunk:1", text: "first " }, + { type: "user_message", id: "user_message_chunk:1", text: "first question" }, + { type: "assistant_message", id: "agent_message_chunk:2", text: "answer" }, + { type: "user_message", id: "user_message_chunk:3", text: "second question" }, + ]); + } finally { + await connection.close(); + } + }); + + it("does not duplicate the live prompt echo when the agent echoes it as a user chunk", async () => { + const harness = connectorHarness({ + capabilities: { loadSession: true }, + promptUpdates: (promptText) => [ + { sessionUpdate: "user_message_chunk", content: { type: "text", text: promptText } }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Acknowledged" } }, + ], + }); + const registration = runAcpProvider({ + id: "echo-acp", + label: "Echo ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "session.persistence"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + const input = openInput(); + input.persistence = { version: 1, data: { sessionId: "connector-session" } }; + input.history = "replay"; + await connection.send(input); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "echo-1", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "hello" }] }, + }, + }); + try { + await waitForEvent( + events, + (event) => + event.type === "session.turn" && + event.turnId === "acp:echo-1" && + event.state === "completed", + ); + expect(timelineProjection(events)).toEqual([ + { type: "user_message", id: "echo-1", text: "hello" }, + { type: "assistant_message", id: "agent_message_chunk:1", text: "Acknowledged" }, + ]); + } finally { + await connection.close(); + } + }); + + it("drops replayed updates when the open request asks to skip history", async () => { + const { connection, events } = await openReplaySession( + [userChunk("What is in the README?", "user-1"), textChunk("It describes the project.")], + "skip", + ); + try { + expect(timelineProjection(events)).toEqual([]); + } finally { + await connection.close(); + } + }); +}); diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 75948ccf72..90ba994043 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.13.0-beta.2", + "version": "0.13.0", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 8f5c495a83..4f0aec8a03 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.13.0-beta.2", + "version": "0.13.0", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 0a7f9edbd4..dbba1c4aa8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.13.0-beta.2", + "version": "0.13.0", "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.13.0-beta.2", - "@getpaseo/highlight": "0.13.0-beta.2", - "@getpaseo/plugin": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", - "@getpaseo/relay": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/highlight": "0.13.0", + "@getpaseo/plugin": "0.13.0", + "@getpaseo/protocol": "0.13.0", + "@getpaseo/relay": "0.13.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts b/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts index 9be65da964..307365b5ac 100644 --- a/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts @@ -7,6 +7,7 @@ import type { AgentTimelineItem } from "../../agent-sdk-types.js"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { ClaudeAgentClient } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; +import { projectTimelineRows } from "../../timeline-projection.js"; describe("Claude session request journal replay", () => { let root: string; @@ -137,4 +138,100 @@ describe("Claude session request journal replay", () => { }), ]); }); + + test.each(["remove", "user"])( + "replays a background completion once across queue enqueue and %s records", + async (delivery) => { + const content = + "\nbg-1\ncompleted\nBackground command completed\n"; + const items = await replay([ + { type: "queue-operation", operation: "enqueue", content }, + delivery === "remove" + ? { type: "queue-operation", operation: "remove", content } + : { + type: "user", + uuid: "notification-delivery", + origin: { kind: "task-notification" }, + message: { role: "user", content }, + }, + ]); + const projected = projectTimelineRows({ + rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })), + mode: "projected", + }); + expect(projected.map((row) => row.item)).toEqual([ + expect.objectContaining({ + type: "tool_call", + name: "task_notification", + status: "completed", + metadata: expect.objectContaining({ taskId: "bg-1" }), + }), + ]); + }, + ); + test("restores each Monitor event and completion of one task once", async () => { + const notifications = ["1", "2", "3", "4"].map( + (event) => + `\nmonitor-1\nMonitor event\n${event}\n`, + ); + notifications.push( + "\nmonitor-1\ncompleted\nMonitor stream ended\n", + ); + const items = await replay( + notifications.flatMap((content, index) => [ + { type: "queue-operation", operation: "enqueue", content }, + { type: "user", uuid: `delivery-${index}`, message: { role: "user", content } }, + ]), + ); + const projected = projectTimelineRows({ + rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })), + mode: "projected", + }); + expect(projected.map(({ item }) => item.type === "tool_call" && item.detail)).toEqual( + notifications.map((text, index) => + expect.objectContaining({ + label: index === 4 ? "Monitor stream ended" : "Monitor event", + text, + }), + ), + ); + }); + test.each(["user", "remove", "system"])( + "preserves identical notifications from separate occurrences with %s deliveries", + async (delivery) => { + const content = + "\nmonitor-repeat\ncompleted\nMonitor event\n"; + const queued = [1, 2].map(() => ({ type: "queue-operation", operation: "enqueue", content })); + const delivered = [1, 2].map((index) => { + if (delivery === "user") { + return { type: "user", uuid: `repeat-${index}`, message: { role: "user", content } }; + } + if (delivery === "remove") { + return { type: "queue-operation", operation: "remove", content }; + } + return { + type: "system", + subtype: "task_notification", + uuid: `repeat-${index}`, + task_id: "monitor-repeat", + status: "completed", + summary: "Monitor event", + }; + }); + const items = await replay([ + ...queued, + ...delivered, + { type: "queue-operation", operation: "enqueue", content }, + ]); + const projected = projectTimelineRows({ + rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })), + mode: "projected", + }); + expect(projected.map(({ item }) => item.type === "tool_call" && item.detail?.label)).toEqual([ + "Monitor event", + "Monitor event", + "Monitor event", + ]); + }, + ); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 8f2ba60907..666e701649 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -834,7 +834,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => { .filter((item) => item.type === "tool_call" && item.name === "task_notification"), ).toEqual([ expect.objectContaining({ - callId: "task_notification_workflow-notification", + callId: expect.stringMatching(/^task_notification_[a-f0-9]+_1$/), status: "completed", }), ]); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 59512d0d44..2ac6fb9020 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -6364,6 +6364,7 @@ export function convertClaudeHistoryEntry( const userTaskNotificationItem = mapTaskNotificationUserContentToToolCall({ content, messageId: userMessageId, + notificationId: typeof entry.notificationId === "string" ? entry.notificationId : undefined, }); if (userTaskNotificationItem) { return [userTaskNotificationItem]; diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts index d538e14c29..18655a9d8d 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts @@ -97,4 +97,17 @@ describe("task-notification-tool-call", () => { expect(item).toBeNull(); }); + + it("keeps notifications without task IDs distinct by their message IDs", () => { + const items = ["message-1", "message-2"].map((messageId) => + mapTaskNotificationUserContentToToolCall({ + content: "completed", + messageId, + }), + ); + expect(items.map((item) => item?.callId)).toEqual([ + "task_notification_message-1", + "task_notification_message-2", + ]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts index 2db4beddb0..b269be70eb 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts @@ -12,6 +12,7 @@ const OptionalNonEmptyTrimmedStringSchema = z.preprocess( ); const TaskNotificationEnvelopeSchema = z.object({ + notificationId: z.string().optional(), messageId: z.string().nullable(), taskId: z.string().nullable(), toolUseId: z.string().nullable(), @@ -35,7 +36,9 @@ const TaskNotificationUserContentSchema = z.union([ const TaskNotificationHistoryRecordSchema = z .object({ + notificationId: z.string().optional(), type: z.string().optional(), + operation: z.string().optional(), subtype: z.string().optional(), uuid: z.string().optional(), message_id: z.string().optional(), @@ -52,6 +55,7 @@ const TaskNotificationHistoryRecordSchema = z const MapTaskNotificationUserContentToToolCallInputSchema = z.object({ content: z.unknown(), messageId: z.string().optional().nullable(), + notificationId: z.string().optional(), }); export type TaskNotificationEnvelope = z.infer; @@ -155,6 +159,7 @@ function parseTaskNotificationFromUserContent( } return TaskNotificationEnvelopeSchema.parse({ + notificationId: parsedInput.data.notificationId, messageId: toNonEmptyString(parsedInput.data.messageId), taskId: readTaskNotificationTagValue({ text: rawText, tagName: "task-id" }), toolUseId: @@ -186,6 +191,7 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio const rawText = toNonEmptyString(systemRecord.content); return TaskNotificationEnvelopeSchema.parse({ + notificationId: systemRecord.notificationId, messageId: toNonEmptyString(systemRecord.uuid) ?? toNonEmptyString(systemRecord.message_id), taskId: toNonEmptyString(systemRecord.task_id) ?? @@ -212,25 +218,76 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio }); } +/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery + * are separate records of the same occurrence; each source counts repeated identical bodies + * independently so the second event is not merged with the first. */ +export function* assignTaskNotificationReplayIds(entries: Iterable): Generator { + const occurrences = new Map>(); + for (const entry of entries) { + const record = TaskNotificationHistoryRecordSchema.safeParse(entry); + if (!record.success) { + yield entry; + continue; + } + const data = record.data; + const envelope = + data.type === "user" + ? parseTaskNotificationFromUserContent({ content: data.message?.content }) + : parseTaskNotificationFromSystemRecord(data); + if (!envelope?.taskId || !data.type) { + yield entry; + continue; + } + const signature = taskNotificationReplaySignature(envelope); + const source = data.type === "queue-operation" ? `queue-${data.operation}` : data.type; + const counts = occurrences.get(signature) ?? new Map(); + const occurrence = (counts.get(source) ?? 0) + 1; + counts.set(source, occurrence); + occurrences.set(signature, counts); + yield { ...entry, notificationId: `task_notification_${signature}_${occurrence}` }; + } +} + +function taskNotificationReplaySignature(envelope: TaskNotificationEnvelope): string { + // Known fields are canonical across XML user/queue records and structured system records. + // Keep the remaining body too: Monitor events share a summary but carry different s. + const remainder = (envelope.rawText ?? "") + .replace(/<\/?task-notification>/gi, "") + .replace( + /<(task-id|tool-use-id|tool_use_id|status|summary|output-file|output_file)>[\s\S]*?<\/\1>/gi, + "", + ) + .trim(); + return createHash("sha1") + .update( + JSON.stringify([ + envelope.taskId, + envelope.toolUseId, + envelope.status, + envelope.summary, + envelope.outputFile, + remainder, + ]), + ) + .digest("hex"); +} + function normalizeTaskNotificationCallIdSegment(segment: string): string | null { const normalized = segment.trim().replace(/[^a-zA-Z0-9._:-]+/g, "_"); return normalized.length > 0 ? normalized : null; } function buildTaskNotificationCallId(envelope: TaskNotificationEnvelope): string { + if (envelope.notificationId) return envelope.notificationId; const messageSegment = envelope.messageId ? normalizeTaskNotificationCallIdSegment(envelope.messageId) : null; - if (messageSegment) { - return `task_notification_${messageSegment}`; - } + if (messageSegment) return `task_notification_${messageSegment}`; const taskSegment = envelope.taskId ? normalizeTaskNotificationCallIdSegment(envelope.taskId) : null; - if (taskSegment) { - return `task_notification_${taskSegment}`; - } + if (taskSegment) return `task_notification_${taskSegment}`; const seed = [envelope.status, envelope.summary, envelope.outputFile, envelope.rawText] diff --git a/packages/server/src/server/agent/providers/claude/transcript-history.ts b/packages/server/src/server/agent/providers/claude/transcript-history.ts index cba606b0e4..f8663f83d0 100644 --- a/packages/server/src/server/agent/providers/claude/transcript-history.ts +++ b/packages/server/src/server/agent/providers/claude/transcript-history.ts @@ -1,3 +1,4 @@ +import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js"; import fs from "node:fs"; import path from "node:path"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; @@ -88,21 +89,24 @@ function indexedRecords( ): Iterable { return { *[Symbol.iterator]() { - for (const location of locations) { - const fd = descriptors.get(location.file)!; - const buffer = Buffer.allocUnsafe(location.length); - let read = 0; - while (read < buffer.length) { - const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read); - if (count === 0) - throw new Error(`Claude transcript was truncated during replay: ${location.file}`); - read += count; - } - const entry = parseRecord(buffer.toString("utf8")); - if (entry) yield entry; - } + yield* assignTaskNotificationReplayIds(readEntries()); }, }; + function* readEntries(): Generator { + for (const location of locations) { + const fd = descriptors.get(location.file)!; + const buffer = Buffer.allocUnsafe(location.length); + let read = 0; + while (read < buffer.length) { + const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read); + if (count === 0) + throw new Error(`Claude transcript was truncated during replay: ${location.file}`); + read += count; + } + const entry = parseRecord(buffer.toString("utf8")); + if (entry) yield entry; + } + } } function isToolResult(entry: TranscriptEntry): boolean { diff --git a/packages/server/src/server/speech/providers/openai/test-utils/prefetched-playback.ts b/packages/server/src/server/speech/providers/openai/test-utils/prefetched-playback.ts new file mode 100644 index 0000000000..406d3056f1 --- /dev/null +++ b/packages/server/src/server/speech/providers/openai/test-utils/prefetched-playback.ts @@ -0,0 +1,70 @@ +import { createServer } from "node:http"; +import { setTimeout as delay } from "node:timers/promises"; +import pino from "pino"; +import { OpenAITTS } from "../tts.js"; +import { TTSManager } from "../../../../agent/tts-manager.js"; + +if (!global.gc) throw new Error("Run with --expose-gc"); + +const sentences = ["First sentence.", "Second sentence.", "Third sentence."]; +const audio = sentences.map((_, index) => Buffer.alloc(192_000, index + 1)); +const requests: string[] = []; +const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const { input } = JSON.parse(Buffer.concat(chunks).toString()) as { input: string }; + requests.push(input); + const body = audio[sentences.indexOf(input)]; + response.writeHead(200, { "Content-Type": "audio/pcm", "Content-Length": body.length }); + response.end(body); +}); + +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +if (!address || typeof address === "string") throw new Error("Missing server address"); +const logger = pino({ level: "silent" }); +const provider = new OpenAITTS( + { apiKey: "test", baseUrl: `http://127.0.0.1:${address.port}/v1` }, + logger, +); +const manager = new TTSManager("prefetched-playback", logger, provider); +const emitted: { bytes: number; value: number; format: string }[] = []; + +try { + await manager.generateAndWaitForPlayback( + sentences.join(" "), + (message) => { + if (message.type !== "audio_output") return; + const buffer = Buffer.from(message.payload.audio, "base64"); + emitted.push({ bytes: buffer.length, value: buffer[0], format: message.payload.format }); + if (emitted.length === 1) { + // Hold the playback acknowledgement while the other responses are prefetched. + void (async () => { + while (requests.length < sentences.length) await delay(10); + for (let cycle = 0; cycle < 20; cycle++) { + await delay(25); + global.gc!(); + } + manager.confirmAudioPlayed(message.payload.id); + })(); + } else { + setImmediate(() => manager.confirmAudioPlayed(message.payload.id)); + } + }, + new AbortController().signal, + true, + ); + console.log(JSON.stringify({ requests, emitted })); +} finally { + manager.cleanup(); + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} diff --git a/packages/server/src/server/speech/providers/openai/tts-playback.test.ts b/packages/server/src/server/speech/providers/openai/tts-playback.test.ts new file mode 100644 index 0000000000..b7b2224144 --- /dev/null +++ b/packages/server/src/server/speech/providers/openai/tts-playback.test.ts @@ -0,0 +1,21 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +test("prefetched speech survives garbage collection until its playback turn", () => { + const fixture = fileURLToPath(new URL("./test-utils/prefetched-playback.ts", import.meta.url)); + const output = execFileSync(process.execPath, ["--expose-gc", "--import", "tsx", fixture], { + encoding: "utf8", + timeout: 15_000, + }); + + const result = JSON.parse(output); + expect(result.requests.sort()).toEqual( + ["First sentence.", "Second sentence.", "Third sentence."].sort(), + ); + expect(result.emitted).toEqual([ + { bytes: 192_000, value: 1, format: "pcm" }, + { bytes: 192_000, value: 2, format: "pcm" }, + { bytes: 192_000, value: 3, format: "pcm" }, + ]); +}); diff --git a/packages/server/src/server/speech/providers/openai/tts.ts b/packages/server/src/server/speech/providers/openai/tts.ts index 31f7b5b263..49f43137ca 100644 --- a/packages/server/src/server/speech/providers/openai/tts.ts +++ b/packages/server/src/server/speech/providers/openai/tts.ts @@ -67,7 +67,9 @@ export class OpenAITTS implements TextToSpeechProvider { | "pcm", }); - const audioStream = response.body as unknown as Readable; + // Prefetched speech can wait through playback of an earlier sentence. Consume + // the response here so fetch cannot collect it and cancel its unread body. + const audioStream = Readable.from([Buffer.from(await response.arrayBuffer())]); const duration = Date.now() - startTime; this.logger.debug({ duration }, "Speech synthesis stream ready"); diff --git a/packages/website/package.json b/packages/website/package.json index 1fb8b0087c..852d40d0ab 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.13.0-beta.2", + "version": "0.13.0", "private": true, "type": "module", "scripts": { diff --git a/plugins/cursor-usage-source/server/usage-reported-pools.test.ts b/plugins/cursor-usage-source/server/usage-reported-pools.test.ts new file mode 100644 index 0000000000..04f672fcd3 --- /dev/null +++ b/plugins/cursor-usage-source/server/usage-reported-pools.test.ts @@ -0,0 +1,92 @@ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { fetchUsage } from "./usage.js"; + +it("shows the reported Pro percentage pools without treating bonus spend as an overage", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-reported-pools-")); + const locator = join(directory, "auth.json"); + try { + await writeFile(locator, JSON.stringify({ accessToken: "fixture-token" })); + const report = await fetchUsage({ store: "file", locator }, async () => + Response.json({ + billingCycleStart: "2026-09-25T07:10:08.000Z", + billingCycleEnd: "2026-10-25T07:10:08.000Z", + planUsage: { + totalSpend: 5717, + includedSpend: 2000, + bonusSpend: 3717, + limit: 2000, + autoPercentUsed: 9.157777777777778, + apiPercentUsed: 35.46666666666667, + totalPercentUsed: 11.54949494949495, + }, + spendLimitUsage: { individualLimit: 100, individualRemaining: 100, limitType: "user" }, + }), + ); + expect(report).toMatchObject({ + status: "available", + windows: [ + { id: "plan_usage", usedPct: 11.54949494949495 }, + { id: "cursor_usage", usedPct: 9.157777777777778 }, + { id: "third_party_usage", usedPct: 35.46666666666667 }, + ], + balances: [], + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it.each([ + ["Pro+", 42015, 7000, 34.583333333333336, 4.6818181818181825, 32.07251908396947], + ["Teams", 77145, 2000, 61.8352380952381, 61.09, 61.716], +] as const)( + "preserves the reported %s pools separately from bonus-inclusive dollars", + async (_plan, totalSpend, limit, autoPercentUsed, apiPercentUsed, totalPercentUsed) => { + const report = await reportFor({ + totalSpend, + limit, + autoPercentUsed, + apiPercentUsed, + totalPercentUsed, + }); + expect(report).toMatchObject({ + status: "available", + windows: [ + { id: "plan_usage", usedPct: totalPercentUsed, summary: true }, + { id: "cursor_usage", usedPct: autoPercentUsed }, + { id: "third_party_usage", usedPct: apiPercentUsed }, + ], + balances: [], + }); + }, +); + +it("retains a zero percentage without inventing absent breakdowns", async () => { + expect(await reportFor({ totalPercentUsed: "0", autoPercentUsed: null })).toMatchObject({ + status: "available", + windows: [{ id: "plan_usage", usedPct: 0, remainingPct: 100 }], + balances: [], + }); +}); + +it("keeps dollar-only reports when the response has no percentages", async () => { + expect(await reportFor({ totalSpend: 1500, remaining: 2500, limit: 4000 })).toMatchObject({ + status: "available", + windows: [], + balances: [{ id: "plan_usage", used: 15, remaining: 25, limit: 40, unit: "usd" }], + }); +}); + +async function reportFor(planUsage: Record) { + const directory = await mkdtemp(join(tmpdir(), "cursor-usage-pools-")); + const locator = join(directory, "auth.json"); + try { + await writeFile(locator, JSON.stringify({ accessToken: "fixture-token" })); + return await fetchUsage({ store: "file", locator }, async () => Response.json({ planUsage })); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/plugins/cursor-usage-source/server/usage.ts b/plugins/cursor-usage-source/server/usage.ts index 2265dbb556..58d5b48ec2 100644 --- a/plugins/cursor-usage-source/server/usage.ts +++ b/plugins/cursor-usage-source/server/usage.ts @@ -9,10 +9,12 @@ import { toneFromUsedPct, usedPctOf, unavailable, + windowFromUsedPct, type UsageAccount, type UsageReport, type UsageBalance, type UsageScope, + type UsageWindow, } from "@getpaseo/plugin/server/usage"; const ApiNumberSchema = z.coerce.number().finite(); @@ -60,6 +62,9 @@ const CursorUsageResponseSchema = z.object({ bonusSpend: ApiNullableNumberSchema, remaining: ApiNullableNumberSchema, limit: ApiNullableNumberSchema, + totalPercentUsed: ApiNullableNumberSchema, + autoPercentUsed: ApiNullableNumberSchema, + apiPercentUsed: ApiNullableNumberSchema, }) .nullish(), billingCycleStart: CursorBillingCycleTimestampSchema, @@ -177,8 +182,29 @@ export async function fetchUsage( const resp = CursorUsageResponseSchema.parse(await res.json()); const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd); + const windows: UsageWindow[] = []; const balances: UsageBalance[] = []; if (resp.planUsage) { + // Total spend includes bonus credits; returned percentages measure quota usage. + for (const [id, label, usedPct] of [ + ["plan_usage", "Total", resp.planUsage.totalPercentUsed], + ["cursor_usage", "Cursor", resp.planUsage.autoPercentUsed], + ["third_party_usage", "Third Party", resp.planUsage.apiPercentUsed], + ] as const) { + if (usedPct === null) continue; + windows.push( + windowFromUsedPct({ + id, + label, + utilizationPct: usedPct, + resetsAt: billingCycleEnd, + summary: id === "plan_usage", + tone: toneFromUsedPct(usedPct), + }), + ); + } + } + if (resp.planUsage && windows.length === 0) { const totalSpend = centsToDollars(resp.planUsage.totalSpend); const remaining = centsToDollars(resp.planUsage.remaining); const limit = centsToDollars(resp.planUsage.limit); @@ -197,7 +223,7 @@ export async function fetchUsage( return { status: "available", planLabel: undefined, - windows: [], + windows, balances, details: [], }; diff --git a/plugins/package.json b/plugins/package.json index 34c57df528..de80b5b54c 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.13.0-beta.2", + "version": "0.13.0", "private": true, "type": "module", "scripts": { diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index f430dff92a..fdefda5971 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -176,8 +176,9 @@ its registration; that registration belongs in the client entry. ### UI imports in v0.13 -Use `/client/ui` for all Paseo UI components and UI hooks. The previous entry remains an -undocumented compatibility alias until 2027-04-20. +Use `/client/ui` for all Paseo UI components and UI hooks. The deprecated +`/client/react-native` entry retains `useRevealedText(text, phase): string` for 0.12 plugins +until removal after 2027-04-20. The hook is deprecated and is not exported from `/client/ui`. For paced timeline text, replace `useRevealedText(text, phase)` and the surrounding `Text` with `` from `/client/ui`.