Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 9496dcf22a feat(app): recall sent messages with Up and Down in an empty composer
Fixing a prompt after interrupting it, or retrying one after a provider
error, meant scrolling up and copying it out of the chat. Up in an empty
agent composer now fills the last message sent to that agent; Up walks
older, Down walks newer and past the newest back to empty.

History is read from the loaded timeline on key press, not kept in a
separate store, so it follows rewinds, reloads and other devices and costs
nothing while a turn streams. Recall only starts from an empty composer, so
it never discards a half-written draft. While a recalled message is
unedited the autocomplete stays hidden, otherwise a recalled `/command`
would open the picker and Enter would pick an option instead of sending.
2026-10-23 14:47:20 +02:00
85 changed files with 560 additions and 2118 deletions
+14 -33
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.13.0 - 2026-10-24
## 0.13.0-beta.1 - 2026-10-22
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
@@ -14,26 +14,18 @@ paseo host add 'relay://...' --label laptop
### Added
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
- Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576))
- Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453))
- Added pasting images and dropping files into terminals, so agent TUIs such as Claude Code and Codex can read them, including on remote hosts ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
### Improved
- 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))
- Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580))
- Replaced the file tab's Preview/Source toggle with a switcher between the file's panels ([#7411](https://github.com/getpaseo/paseo/pull/7411))
- Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- 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))
- Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
@@ -41,46 +33,35 @@ paseo host add 'relay://...' --label laptop
- Reduced app startup work by loading only the selected language's translations ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8))
- Removed repeated workspace layout saves when nothing changed ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8))
### Deprecated
- Deprecated plugin panel `context` and flat `agentId` props, and `addWorkspacePanel`'s `context` field (including the agent registration alias). Removal after 2027-04-23; use `params.agentId` and `addAgentPanel`.
### Changed
- Changed beta file panel props to `workspaceId`, read-only `params`, `revision`, `file`, and `tab` with persisted per-tab state. Removed beta-only `location`, `PluginFileLocation`, and `PluginFileTab`.
### Plugins
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614))
- Added `useTabState` for per-tab panel state that survives reloads, and `addAgentPanel`; workspace, agent and file panels share the `workspaceId`, `params`, `revision` and `tab` props ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
- Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478))
- 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, 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 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 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
- Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573))
- Fixed parent agents never hearing from `notifyOnFinish` children after a daemon restart ([#7683](https://github.com/getpaseo/paseo/pull/7683))
- Fixed opening a large Claude session showing an empty chat or crashing the daemon ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
- Fixed Import session crashing the daemon with large Claude histories ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
- Fixed hosts staying in Error when the direct connection drops instead of switching to a saved relay connection ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed tapping a notification opening a stale workspace on iOS, and Android reopening the notification's agent on every app reopen ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
- Fixed sidebar PR status and checks disappearing after the app reconnects ([#7638](https://github.com/getpaseo/paseo/pull/7638))
- 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))
- Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538))
- 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" 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 Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- 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))
+6 -33
View File
@@ -1,6 +1,6 @@
# 更新日志
## 0.13.0 - 2026-10-24
## 0.13.0-beta.1 - 2026-10-22
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
@@ -14,26 +14,18 @@ paseo host add 'relay://...' --label laptop
### 新增
- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- 新增已连接主机,Agent 可以在其他机器上创建、提示和监控 Agent,并在 Subagents 中查看它们 ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
- 新增简体中文版 paseo.sh,地址为 [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Changes 窗格新增 All changes 并设为默认,在一个 diff 中显示自分支基点以来已提交和未提交的工作 ([#7576](https://github.com/getpaseo/paseo/pull/7576))
- 如果进入 Plan 前开启了 Auto,Claude 计划审批新增 Implement with Auto mode ([#7453](https://github.com/getpaseo/paseo/pull/7453))
- 新增在终端中粘贴图片和拖放文件,Claude Code、Codex 等 Agent TUI 可以读取它们,远程主机同样适用 ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
### 改进
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#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))
- 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580))
- 文件标签页的 Preview/Source 切换改为在该文件的各个面板之间切换 ([#7411](https://github.com/getpaseo/paseo/pull/7411))
- Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- 手机上的 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))
- 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
@@ -43,44 +35,25 @@ paseo host add 'relay://...' --label laptop
### 插件
- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614))
- 新增 `useTabState`,用于保存每个标签页的面板状态并在重新加载后恢复,并新增 `addAgentPanel`;工作区、Agent 和文件面板共用 `workspaceId`、`params`、`revision` 和 `tab` 属性 ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
- 新增[插件翻译](https://paseo.sh/zh-CN/docs/plugins/reference#runtime-translations),插件内容和标签会跟随应用的语言 ([#7478](https://github.com/getpaseo/paseo/pull/7478))
- 新增插件列表翻译,可通过 `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`;原入口(包括 `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))
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474))
- 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))
### 修复
- 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573))
- 修复了 daemon 重启后父 Agent 收不到 `notifyOnFinish` 子 Agent 通知的问题 ([#7683](https://github.com/getpaseo/paseo/pull/7683))
- 修复了打开大型 Claude 会话时聊天为空或 daemon 崩溃的问题 ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
- 修复了 Claude 历史记录较大时导入会话导致 daemon 崩溃的问题 ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
- 修复了直连断开后主机停留在错误状态、不切换到已保存的 relay 连接的问题 ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
- 修复了 iOS 上点击通知打开过期工作区,以及 Android 每次重新打开应用都会再次打开通知对应 Agent 的问题 ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
- 修复了应用重新连接后侧边栏中的 PR 状态和检查消失的问题 ([#7638](https://github.com/getpaseo/paseo/pull/7638))
- 修复了没有应用显示工作区时,工作区 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))
- 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538))
- 修复了 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), [#7701](https://github.com/getpaseo/paseo/pull/7701))
- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- 修复了 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))
@@ -1,15 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,15 +0,0 @@
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
+1 -1
View File
@@ -1 +1 @@
sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -35695,7 +35695,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37961,12 +37961,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/server": "0.13.0-beta.1",
"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",
"version": "0.13.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -38228,7 +38228,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"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",
"version": "0.13.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -42124,7 +42124,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"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",
"version": "0.13.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@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",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"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",
"version": "0.13.0-beta.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -42221,7 +42221,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -42442,16 +42442,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/highlight": "0.13.0-beta.1",
"@getpaseo/plugin": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"@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",
"version": "0.13.0-beta.1",
"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",
"version": "0.13.0-beta.1",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,81 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
function userMessages(page: Page) {
return page.getByTestId("user-message");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await submitMessage(page, text);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, "");
}
async function pressInComposer(page: Page, key: string) {
await composerLocator(page).press(key);
}
test.describe("Composer recall", () => {
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-",
title: "Composer recall",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "fix the auth bug", 1);
await sendAndWaitForMessage(page, "add tests", 2);
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "fix the auth bug");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "");
await pressInComposer(page, "ArrowUp");
await typeIntoFocusedComposer(page, " for login");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "Enter");
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
await session.cleanup();
}
});
test("a recalled slash message does not open the command picker", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-slash-",
title: "Composer recall slash",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "/tdd", 1);
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
} finally {
await session.cleanup();
}
});
});
@@ -1,100 +0,0 @@
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<string> {
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<number>> {
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();
}
});
});
@@ -43,8 +43,6 @@ 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,
@@ -55,74 +53,6 @@ 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);
@@ -217,42 +147,6 @@ 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,
}) => {
@@ -10,16 +10,6 @@ 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;
@@ -37,9 +27,7 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page)
const sample = () => {
if (capture.stopped) return;
const root = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="message-input-root"]'),
).find((candidate) => candidate.offsetParent !== null);
const root = document.querySelector<HTMLElement>('[data-testid="message-input-root"]');
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
if (root && input) {
const style = getComputedStyle(input);
@@ -190,8 +178,6 @@ 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();
@@ -208,7 +194,6 @@ 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,
@@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi
}
});
async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") {
async function selectWorkspaceSort(page: Page, name: "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,60 +362,3 @@ 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();
}
});
@@ -72,9 +72,6 @@ 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");
}
@@ -1,40 +0,0 @@
// 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 <session> <adb-serial>
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}`);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -28,23 +28,6 @@ function failure(message: string) {
}
describe("direct connection failure copy", () => {
it.each([
"paseo-qa-nonexistent.invalid",
"ssl-paseo-qa.invalid",
"tls-paseo-qa.invalid",
"certificate-paseo-qa.invalid",
])(
"identifies Android DNS failures for %s without treating the hostname as TLS evidence",
(host) => {
const message = `Unable to resolve host "${host}": No address associated with hostname`;
expect(failure(message)).toEqual({
title: "Failed: tcp://example.test:443?ssl=true",
detail: labels.hostNotFound,
raw: `transport error — ${message}`,
});
},
);
it("identifies Android certificate validation errors without calling them DNS failures", () => {
const message =
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
@@ -60,7 +43,6 @@ describe("direct connection failure copy", () => {
["SSL handshake failed", labels.tlsError],
["TLS handshake failed", labels.tlsError],
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
["getaddrinfo ENOTFOUND ssl.example.test", labels.hostNotFound],
["Host not found", labels.hostNotFound],
["connect ECONNREFUSED", labels.refused],
["connection timed out", labels.timedOut],
@@ -83,8 +83,6 @@ export function buildConnectionFailureCopy(input: {
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (rawLower.includes("unable to resolve host") || rawLower.includes("enotfound")) {
detail = labels.hostNotFound;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("certpathvalidator") ||
@@ -93,7 +91,7 @@ export function buildConnectionFailureCopy(input: {
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else if (rawLower.includes("not found")) {
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
+2 -5
View File
@@ -719,11 +719,8 @@ function DesktopSidebar({
[active, resizeAnimatedStyle],
);
const desktopSidebarBorderStyle = useMemo(
() => [
styles.desktopSidebarBorder,
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
],
[insetsTop, insets.bottom],
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop],
);
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
@@ -1550,11 +1550,6 @@ function ProjectBlock({
shortcutIndexByWorkspaceKey,
onToggleCollapsed,
onWorkspacePress,
parentGestureRef,
onWorkspaceReorder,
useNestable,
dragGestureHostActive,
manualSort,
onWorktreeCreated,
drag,
isDragging,
@@ -1576,11 +1571,6 @@ function ProjectBlock({
shortcutIndexByWorkspaceKey: Map<string, number>;
onToggleCollapsed: (projectViewKey: string) => void;
onWorkspacePress?: () => void;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
useNestable: boolean;
dragGestureHostActive?: boolean;
manualSort: boolean;
onWorktreeCreated?: (workspaceId: string) => void;
drag: () => void;
isDragging: boolean;
@@ -1634,14 +1624,7 @@ function ProjectBlock({
});
const renderWorkspaceRow = useCallback(
(
item: SidebarWorkspacePlacement,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
) => {
(item: SidebarWorkspacePlacement) => {
return (
<MemoWorkspaceRowItem
key={item.workspaceKey}
@@ -1657,9 +1640,6 @@ function ProjectBlock({
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
);
},
@@ -1678,29 +1658,6 @@ function ProjectBlock({
],
);
const renderWorkspace = useCallback(
({
item,
drag: workspaceDrag,
isActive,
dragHandleProps: workspaceDragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
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);
@@ -1766,29 +1723,12 @@ function ProjectBlock({
if (project.workspaces.length > 0) {
projectChildren = (
<>
{manualSort ? (
<DraggableList
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
) : (
<View
testID={`sidebar-workspace-list-${project.viewKey}`}
style={styles.workspaceListContainer}
>
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
</View>
)}
<View
testID={`sidebar-workspace-list-${project.viewKey}`}
style={styles.workspaceListContainer}
>
{visibleWorkspaces.map(renderWorkspaceRow)}
</View>
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
@@ -1863,11 +1803,6 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
previous.useNestable === next.useNestable &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.manualSort === next.manualSort &&
previous.onWorktreeCreated === next.onWorktreeCreated &&
previous.drag === next.drag &&
previous.isDragging === next.isDragging &&
@@ -2177,9 +2112,6 @@ function ProjectModeList({
(state) => 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);
@@ -2279,30 +2211,6 @@ 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);
@@ -2351,11 +2259,6 @@ 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}
@@ -2373,10 +2276,6 @@ function ProjectModeList({
collapsedProjectKeys,
activeWorkspaceSelection,
handleWorktreeCreated,
manualSort,
parentGestureRef,
dragGestureHostActive,
handleWorkspaceReorder,
hostBadgeByServerId,
supportsMultiplicityByServerId,
supportsPinningByServerId,
@@ -244,8 +244,10 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
definitions.push({
id: "sort",
title: t("sidebar.display.sort.label"),
content: (
<SortPage preferences={preferences} automaticSortSupported={workspaceSortSupported} />
content: workspaceSortSupported ? (
<SortPage preferences={preferences} />
) : (
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
),
});
}
@@ -323,7 +325,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
<MenuSubTrigger
id="sort"
value={
workspaceSortSupported || preferences.workspaceSort === "manual"
workspaceSortSupported
? t(`sidebar.display.sort.${preferences.workspaceSort}`)
: undefined
}
@@ -397,38 +399,19 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
);
}
function SortPage({
preferences,
automaticSortSupported,
}: {
preferences: Preferences;
automaticSortSupported: boolean;
}): ReactElement {
function SortPage({ preferences }: { preferences: Preferences }): ReactElement {
const { t } = useTranslation();
const { setWorkspaceSort } = preferences;
const selectManual = useCallback(() => setWorkspaceSort("manual"), [setWorkspaceSort]);
const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]);
const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]);
return (
<>
<MenuItem selected={preferences.workspaceSort === "manual"} onSelect={selectManual}>
{t("sidebar.display.sort.manual")}
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
{t("sidebar.display.sort.created")}
</MenuItem>
<MenuItem selected={preferences.workspaceSort === "lastPrompt"} onSelect={selectLastPrompt}>
{t("sidebar.display.sort.lastPrompt")}
</MenuItem>
{automaticSortSupported ? (
<>
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
{t("sidebar.display.sort.created")}
</MenuItem>
<MenuItem
selected={preferences.workspaceSort === "lastPrompt"}
onSelect={selectLastPrompt}
>
{t("sidebar.display.sort.lastPrompt")}
</MenuItem>
</>
) : (
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
)}
</>
);
}
+23 -32
View File
@@ -28,7 +28,6 @@ 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";
@@ -911,9 +910,13 @@ function resolveInitialActiveIndex(
return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
}
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
interface MobileBodyProps {
isOpen: boolean;
bottomSheetRef: BottomSheetVisibility["sheetRef"];
snapPoints: string[];
handleSheetChange: BottomSheetVisibility["handleSheetChange"];
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
handleIndicatorStyle: { backgroundColor: string };
titleColor: string;
title: string;
@@ -941,22 +944,7 @@ interface MobileBodyProps {
safeAreaBottom: number;
}
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
const active = useRetainedPanelActive();
if (!active) return null;
// The visibility tracker must leave the tree with its sheet.
return <MobileComboboxSheet {...props} />;
}
function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
const {
sheetRef: bottomSheetRef,
handleSheetChange,
handleSheetDismiss,
} = useIsolatedBottomSheetVisibility({
visible: props.isOpen,
onClose: props.onClose,
});
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
const comboboxTitleStyle = useMemo(
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
@@ -991,13 +979,13 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
return (
<IsolatedBottomSheetModal
ref={bottomSheetRef}
ref={props.bottomSheetRef}
contextBridge={null}
snapPoints={props.snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
onChange={props.handleSheetChange}
onDismiss={props.handleSheetDismiss}
backdropOpacity={0.45}
enablePanDownToClose
backgroundComponent={ComboboxSheetBackground}
@@ -1180,8 +1168,7 @@ function DesktopComboboxOptionsBody(props: {
);
}
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
const active = useRetainedPanelActive();
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
const handleDesktopKey = props.handleDesktopKey;
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
@@ -1203,9 +1190,6 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
[props.refs, setWebOverlayScope],
);
// Remove the portal before a retained chat can freeze its closing update.
if (!active) return null;
const overlay = (
<OverlayLayerProvider layer={props.overlayLayer}>
<View
@@ -1325,7 +1309,6 @@ export function Combobox({
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
const resolvedTitle = title ?? t("common.actions.select");
const isMobile = useIsCompactFormFactor();
const active = useRetainedPanelActive();
const floatingLayer = useOverlayLayer("floating");
const safeAreaInsets = useSafeAreaInsets();
const titleColor = theme.colors.foreground;
@@ -1365,10 +1348,6 @@ export function Combobox({
bumpSearchResetKey();
}, [setOpen, setSearchQueryWithCallback]);
useEffect(() => {
if (!active && isOpen) handleClose();
}, [active, isOpen, handleClose]);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
const collisionPadding = useMemo(computeCollisionPadding, []);
@@ -1434,6 +1413,16 @@ 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(
@@ -1583,8 +1572,10 @@ export function Combobox({
if (isMobile) {
return (
<MobileComboboxBody
isOpen={isOpen}
bottomSheetRef={bottomSheetRef}
snapPoints={snapPoints}
handleSheetChange={handleSheetChange}
handleSheetDismiss={handleSheetDismiss}
handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor}
title={resolvedTitle}
+27 -7
View File
@@ -55,7 +55,11 @@ import {
} from "@/composer/agent-controls";
import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
import {
selectAgentSentMessages,
selectAgentTurnPresentation,
useSessionStore,
} from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
import { useFileDrop } from "@/components/file-drop/use-file-drop";
import type { DroppedItem } from "@/components/file-drop/types";
@@ -118,6 +122,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit";
import { ComposerRecall } from "@/composer/recall";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
@@ -995,6 +1000,7 @@ interface ComposerAutocompleteHandle {
function ComposerAutocompleteBinding({
text,
cursor,
recall,
configuration,
inputRef,
anchorRef,
@@ -1003,6 +1009,7 @@ function ComposerAutocompleteBinding({
}: {
text: ComposerTextSource;
cursor: StoreApi<number>;
recall: ComposerRecall;
configuration: Omit<
Parameters<typeof useAgentAutocomplete>[0],
"userInput" | "cursorIndex" | "onAutocompleteApplied"
@@ -1020,9 +1027,11 @@ function ComposerAutocompleteBinding({
cursorIndex: Math.min(cursorIndex, userInput.length),
onAutocompleteApplied: () => inputRef.current?.focus(),
});
// A recalled message is sent as is; its leading `/command` must not open the picker.
const isShown = show && !recall.isShowing(userInput);
const handleKeyPress = autocomplete.onKeyPress;
useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [
show,
useImperativeHandle(ref, () => ({ onKeyPress: (event) => isShown && handleKeyPress(event) }), [
isShown,
handleKeyPress,
]);
const selectOption = autocomplete.onSelectOption;
@@ -1032,7 +1041,7 @@ function ComposerAutocompleteBinding({
);
return (
<ComposerAutocomplete
visible={autocomplete.isVisible && show}
visible={autocomplete.isVisible && isShown}
anchorRef={anchorRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
@@ -1362,6 +1371,7 @@ function ComposerContentImpl({
);
useEffect(() => () => cursorPublication.cancel(), [cursorPublication]);
const autocompleteRef = useRef<ComposerAutocompleteHandle>(null);
const recall = useMemo(() => new ComposerRecall(), []);
const [isProcessing, setIsProcessing] = useState(false);
const [pendingFiles, setPendingFiles] = useState<PendingFileAttachment[]>([]);
const nextPendingFileId = useRef(0);
@@ -1981,10 +1991,19 @@ function ComposerContentImpl({
const hasSendableContent = hasText || selectedAttachments.length > 0;
// Handle keyboard navigation for command autocomplete.
// An open autocomplete owns the arrows; otherwise they may recall sent messages.
const handleCommandKeyPress = useCallback(
(event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false,
[],
(event: ComposerKeyPressEvent) => {
if (autocompleteRef.current?.onKeyPress(event)) return true;
const recalledText = recall.handleKeyPress(event, () =>
selectAgentSentMessages(useSessionStore.getState().sessions[serverId], agentId),
);
if (recalledText === null) return false;
event.preventDefault();
replaceUserInput(recalledText);
return true;
},
[agentId, recall, replaceUserInput, serverId],
);
const cancelButtonStyle = useMemo(
@@ -2423,6 +2442,7 @@ function ComposerContentImpl({
<ComposerAutocompleteBinding
text={textSource}
cursor={cursor}
recall={recall}
inputRef={messageInputRef}
anchorRef={messageInputContainerRef}
show={mode.showAutocomplete && !isFullscreen}
+6 -2
View File
@@ -110,6 +110,8 @@ export interface ComposerInputSnapshot {
export interface ComposerKeyPressEvent {
key: string;
/** Shift, Alt, Ctrl, or Meta was held. */
hasModifier: boolean;
preventDefault: () => void;
input: ComposerInputSnapshot;
}
@@ -210,6 +212,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
metaKey?: boolean;
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
isComposing?: boolean;
keyCode?: number;
@@ -411,17 +414,18 @@ function handleDesktopKeyPressImpl(
): void {
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
const { shiftKey, metaKey, ctrlKey, altKey } = event.nativeEvent;
if (ctx.onKeyPressCallback) {
const handled = ctx.onKeyPressCallback({
key: event.nativeEvent.key,
hasModifier: Boolean(shiftKey || metaKey || ctrlKey || altKey),
preventDefault: () => event.preventDefault(),
input: ctx.input,
});
if (handled) return;
}
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
if (event.nativeEvent.key !== "Enter") return;
if (!ctx.submitOnEnter) return;
if (shiftKey) return;
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import type { ComposerKeyPressEvent } from "@/composer/input/input";
import { ComposerRecall } from "@/composer/recall";
const SENT = ["add tests", "fix the auth bug"];
function press(
key: string,
text: string,
options: { caret?: number; selectionEnd?: number; hasModifier?: boolean } = {},
): ComposerKeyPressEvent {
const caret = options.caret ?? text.length;
return {
key,
hasModifier: options.hasModifier ?? false,
preventDefault: () => undefined,
input: { text, selection: { start: caret, end: options.selectionEnd ?? caret } },
};
}
/** Drives recall the way the composer does: each result becomes the composer text, caret at the end. */
function composerWithRecall(sent: readonly string[] = SENT) {
const recall = new ComposerRecall();
let text = "";
return {
recall,
get text() {
return text;
},
type(next: string) {
text = next;
},
press(key: string, options: { caret?: number; hasModifier?: boolean } = {}) {
const result = recall.handleKeyPress(press(key, text, options), () => sent);
if (result !== null) text = result;
return result;
},
};
}
describe("ComposerRecall", () => {
it("walks back through sent messages and forward to an empty composer", () => {
const composer = composerWithRecall();
expect(composer.press("ArrowUp")).toBe("add tests");
expect(composer.press("ArrowUp")).toBe("fix the auth bug");
expect(composer.press("ArrowDown")).toBe("add tests");
expect(composer.press("ArrowDown")).toBe("");
expect(composer.recall.isShowing(composer.text)).toBe(false);
});
it("leaves the caret to the input past the oldest message", () => {
const composer = composerWithRecall();
composer.press("ArrowUp");
composer.press("ArrowUp");
expect(composer.press("ArrowUp")).toBeNull();
expect(composer.text).toBe("fix the auth bug");
});
it("only starts from an empty composer", () => {
const composer = composerWithRecall();
composer.type("half written");
expect(composer.press("ArrowUp")).toBeNull();
expect(composer.press("ArrowDown")).toBeNull();
});
it("ignores Down from an empty composer and Up with no history", () => {
expect(composerWithRecall().press("ArrowDown")).toBeNull();
expect(composerWithRecall([]).press("ArrowUp")).toBeNull();
});
it("hands the arrows back to the caret once the recalled message is edited", () => {
const composer = composerWithRecall();
composer.press("ArrowUp");
composer.type("add tests for login");
expect(composer.recall.isShowing(composer.text)).toBe(false);
expect(composer.press("ArrowUp")).toBeNull();
expect(composer.press("ArrowDown")).toBeNull();
});
it("moves between lines of a multi-line message before changing message", () => {
const composer = composerWithRecall(["first line\nsecond line", "older"]);
composer.press("ArrowUp");
expect(composer.press("ArrowUp", { caret: "first line\nsec".length })).toBeNull();
expect(composer.press("ArrowUp", { caret: 3 })).toBe("older");
expect(composer.press("ArrowDown")).toBe("first line\nsecond line");
expect(composer.press("ArrowDown", { caret: 3 })).toBeNull();
expect(composer.press("ArrowDown")).toBe("");
});
it("ignores modified arrows, other keys, and a selection", () => {
const composer = composerWithRecall();
expect(composer.press("ArrowUp", { hasModifier: true })).toBeNull();
expect(composer.press("Enter")).toBeNull();
composer.press("ArrowUp");
const recall = composer.recall;
expect(
recall.handleKeyPress(
press("ArrowUp", "add tests", { caret: 0, selectionEnd: 3 }),
() => SENT,
),
).toBeNull();
});
});
+68
View File
@@ -0,0 +1,68 @@
import type { ComposerKeyPressEvent } from "@/composer/input/input";
interface RecalledMessage {
index: number;
text: string;
}
/**
* Walks the messages already sent to an agent from an empty composer, like a
* shell's Up/Down history. A message stays recalled only while the composer
* still holds its exact text; the first edit hands the arrows back to the caret.
*/
export class ComposerRecall {
private recalled: RecalledMessage | null = null;
/** True while the composer shows a recalled message the user has not edited. */
isShowing(text: string): boolean {
return this.recalled !== null && this.recalled.text === text;
}
/**
* Returns the text the composer should show for this key press, or null to
* leave the key to the input. `readSentMessages` is newest first and is only
* read for keys recall acts on.
*/
handleKeyPress(
event: ComposerKeyPressEvent,
readSentMessages: () => readonly string[],
): string | null {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return null;
if (event.hasModifier) return null;
const { text, selection } = event.input;
if (selection.start !== selection.end) return null;
const recalled = this.isShowing(text) ? this.recalled : null;
if (!recalled) {
this.recalled = null;
if (event.key !== "ArrowUp" || text.length > 0) return null;
return this.show(readSentMessages(), 0);
}
if (event.key === "ArrowUp") {
if (!isCaretOnFirstLine(text, selection.start)) return null;
return this.show(readSentMessages(), recalled.index + 1);
}
if (!isCaretOnLastLine(text, selection.start)) return null;
if (recalled.index === 0) {
this.recalled = null;
return "";
}
return this.show(readSentMessages(), recalled.index - 1);
}
private show(messages: readonly string[], index: number): string | null {
const text = messages[index];
if (text === undefined) return null;
this.recalled = { index, text };
return text;
}
}
function isCaretOnFirstLine(text: string, caret: number): boolean {
return !text.slice(0, caret).includes("\n");
}
function isCaretOnLastLine(text: string, caret: number): boolean {
return !text.slice(caret).includes("\n");
}
+29 -29
View File
@@ -38,10 +38,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.36.0",
version: "0.33.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.36.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -60,10 +60,10 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.73",
version: "3.0.46",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.73", "--acp"],
command: ["npx", "-y", "cline@3.0.46", "--acp"],
},
{
id: "codebuddy-code",
@@ -123,10 +123,10 @@ const CATALOG_DATA = [
id: "deepagents",
title: "DeepAgents",
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
version: "0.1.36",
version: "0.1.20",
iconId: "deepagents",
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
command: ["npx", "-y", "deepagents-acp@0.1.36"],
command: ["npx", "-y", "deepagents-acp@0.1.20"],
},
{
id: "devin",
@@ -141,29 +141,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.5.21",
version: "0.2.36",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.5.21", "acp"],
command: ["npx", "-y", "dimcode@0.2.36", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.5.23",
version: "0.4.22",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.5.23", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.249.0",
version: "0.179.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.249.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.179.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -174,19 +174,19 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.10.47",
version: "0.9.22",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.10.47", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.9.22", "fast-agent-acp", "-x"],
},
{
id: "gemini",
title: "Gemini CLI",
description: "Google's official CLI for Gemini",
version: "0.65.0",
version: "0.52.0",
iconId: "gemini",
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.65.0", "--acp"],
command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"],
},
{
id: "gjc",
@@ -205,11 +205,11 @@ const CATALOG_DATA = [
id: "glm-acp-agent",
title: "GLM Agent",
description:
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.3, glm-5.3-flash, glm-5-turbo, glm-4.7). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
version: "1.15.0",
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
version: "1.3.0",
iconId: "glm-acp-agent",
installLink: "https://github.com/stefandevo/glm-acp-agent",
command: ["npx", "-y", "glm-acp-agent@1.15.0"],
command: ["npx", "-y", "glm-acp-agent@1.3.0"],
},
{
id: "goose",
@@ -289,20 +289,20 @@ const CATALOG_DATA = [
id: "minimax-code",
title: "MiniMax Code",
description: "MiniMax's coding agent for the terminal",
version: "0.6.13",
version: "0.1.2",
iconId: "minimax-code",
installLink: "https://agent.minimax.io",
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"],
},
{
id: "minion-code",
title: "Minion Code",
description:
"An enhanced AI code assistant built on the Minion framework with rich development tools",
version: "0.1.45",
version: "0.1.44",
iconId: "minion-code",
installLink: "https://github.com/femto/minion-code",
command: ["uvx", "--from", "minion-code==0.1.45", "minion-code", "acp"],
command: ["uvx", "--from", "minion-code==0.1.44", "minion-code", "acp"],
},
{
id: "mistral-vibe",
@@ -317,10 +317,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.59",
version: "1.1.29",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.59", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"],
},
{
id: "poolside",
@@ -335,19 +335,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.1.78",
version: "1.1.4",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.78", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.4", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.26.0",
version: "0.20.1",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.26.0", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"],
},
{
id: "sigit",
@@ -617,7 +617,7 @@ describe("computeSidebarOrderUpdates", () => {
persistedProjectOrder: ["stale-project"],
});
expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] });
expect(updates).toEqual({ projectOrder: null });
});
it("appends unseen projects without changing saved project order", () => {
@@ -979,20 +979,3 @@ 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([]);
});
@@ -535,57 +535,21 @@ 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),
});
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 };
return {
projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder,
};
}
export interface SidebarLoadingState {
@@ -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.6.13",
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
version: "0.1.2",
command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"],
});
expect(findProvider("minimax-code").iconSvg).toContain("<svg");
});
@@ -144,10 +144,7 @@ export function useSidebarWorkspacesList(options?: {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes");
const workspaceSortSupported = [...sortSupport.values()].every(Boolean);
const sort =
groupMode === "project" && (workspaceSort === "manual" || workspaceSortSupported)
? workspaceSort
: undefined;
const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined;
const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort);
const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []);
@@ -169,16 +166,12 @@ export function useSidebarWorkspacesList(options?: {
const updates = computeSidebarOrderUpdates({
projects,
persistedProjectOrder,
getWorkspaceOrder: sort === "manual" ? orderStore.getWorkspaceOrder : undefined,
});
if (updates.projectOrder) {
orderStore.setProjectOrder(updates.projectOrder);
}
for (const update of updates.workspaceOrders) {
orderStore.setWorkspaceOrder(update.projectViewKey, update.order);
}
}, [persistedProjectOrder, projects, sort]);
}, [persistedProjectOrder, projects]);
const refreshAll = useCallback(() => {
if (!isActive) return;
-1
View File
@@ -1169,7 +1169,6 @@ export const ar: TranslationResources = {
sort: {
label: "الترتيب",
created: "تاريخ الإنشاء",
manual: "يدوي",
lastPrompt: "آخر طلب",
updateHost: "حدّث المضيفين لترتيب مساحات العمل",
},
-1
View File
@@ -1177,7 +1177,6 @@ export const en = {
sort: {
label: "Sort",
created: "Created",
manual: "Manual",
lastPrompt: "Last prompt",
updateHost: "Update hosts to sort workspaces",
},
-1
View File
@@ -1205,7 +1205,6 @@ 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",
},
-1
View File
@@ -1198,7 +1198,6 @@ 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",
},
-1
View File
@@ -1182,7 +1182,6 @@ export const ja: TranslationResources = {
sort: {
label: "並び順",
created: "作成日時",
manual: "手動",
lastPrompt: "最後のプロンプト",
updateHost: "ワークスペースを並べ替えるにはホストを更新してください",
},
-1
View File
@@ -1176,7 +1176,6 @@ export const ko: TranslationResources = {
sort: {
label: "정렬",
created: "생성일",
manual: "수동",
lastPrompt: "마지막 프롬프트",
updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요",
},
-1
View File
@@ -1195,7 +1195,6 @@ 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",
},
-1
View File
@@ -1186,7 +1186,6 @@ export const ru: TranslationResources = {
sort: {
label: "Сортировка",
created: "Дата создания",
manual: "Вручную",
lastPrompt: "Последний запрос",
updateHost: "Обновите хосты для сортировки рабочих пространств",
},
-1
View File
@@ -1161,7 +1161,6 @@ export const zhCN: TranslationResources = {
sort: {
label: "排序",
created: "创建时间",
manual: "手动",
lastPrompt: "最近提示词",
updateHost: "请更新主机以排序工作区",
},
-16
View File
@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate";
const runtime = {
@@ -706,21 +705,6 @@ 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(
+1 -6
View File
@@ -2,7 +2,6 @@ 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";
@@ -115,10 +114,6 @@ export type PluginClientRuntime = Pick<
| "addHeaderButton"
> & { hosts: ReturnType<typeof createPluginHosts> };
// 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,
@@ -508,7 +503,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 pluginReactNativeCompatRuntime;
if (name === "@getpaseo/plugin/client/react-native") return pluginUiRuntime;
if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime;
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
@@ -30,7 +30,7 @@ interface WorkspaceStructureSession {
workspaces: Iterable<WorkspaceDescriptor>;
}
export type WorkspaceSort = "manual" | "created" | "lastPrompt";
export type WorkspaceSort = "created" | "lastPrompt";
interface WorkspaceSortItem {
workspaceName: string;
@@ -266,115 +266,3 @@ 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);
}
});
@@ -82,12 +82,7 @@ export class WorkspaceDirectoryReplica {
}
acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): DirectoryReplicaMutation[] {
// 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);
});
return workspaces.flatMap((workspace) => this.upsertWorkspace(workspace));
}
acceptProject(project: ProjectDescriptor): DirectoryReplicaMutation[] {
@@ -117,17 +117,13 @@ 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, workspaceOrderByProject],
[projectOrder, projects],
);
}
@@ -640,27 +640,3 @@ 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",
]);
});
@@ -254,7 +254,6 @@ export function selectProjectOrder(state: SidebarOrderSnapshot): string[] {
export function composeWorkspaceStructure(input: {
projects: WorkspaceStructureProject[];
projectOrder: readonly string[];
workspaceOrderByProject?: Readonly<Record<string, string[]>>;
}): WorkspaceStructure {
if (input.projects.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE;
@@ -266,21 +265,7 @@ export function composeWorkspaceStructure(input: {
getKey: (project) => project.viewKey,
});
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;
}),
};
return { projects: orderedProjects };
}
export function selectWorkspaceKeys(state: SessionsSnapshot, serverId: string | null): string[] {
@@ -5,6 +5,7 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeWorkspaceDescriptor,
selectAgentSentMessages,
selectAgentTurnPresentation,
selectAgentTimelineState,
useSessionStore,
@@ -235,6 +236,25 @@ describe("agent timeline state", () => {
).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" });
});
it("lists sent messages newest first, including the live head", () => {
initializeTestSession();
const sent = (id: string, text: string) => ({ ...submittedMessage(id), text });
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
items: [sent("m1", "fix the auth bug"), sent("m2", "add tests"), sent("m3", "add tests")],
head: [sent("m4", " "), sent("m5", "refactor the wrong module")],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
older: "none",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
expect(
selectAgentSentMessages(useSessionStore.getState().sessions["test-server"], "agent-1"),
).toEqual(["refactor the wrong module", "add tests", "fix the auth bug"]);
expect(selectAgentSentMessages(undefined, "agent-1")).toEqual([]);
});
it("preserves older availability when an applied response leaves it unchanged", () => {
initializeTestSession();
const store = useSessionStore.getState();
+20
View File
@@ -332,6 +332,26 @@ export function selectAgentTimelineState(
return items.length > 0 ? { status: "painted", items } : { status: "cold" };
}
/** Text of the loaded user messages, newest first, with back-to-back repeats collapsed. */
export function selectAgentSentMessages(
session: SessionState | undefined,
agentId: string,
): string[] {
if (!session) return [];
const items = [
...(session.agentStreamTail.get(agentId) ?? []),
...(session.agentStreamHead.get(agentId) ?? []),
];
const messages: string[] = [];
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item.kind !== "user_message" || item.text.trim().length === 0) continue;
if (messages[messages.length - 1] === item.text) continue;
messages.push(item.text);
}
return messages;
}
export function selectAgentTurnPresentation(
session: SessionState | undefined,
agentId: string,
@@ -6,7 +6,7 @@ import {
import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store";
describe("migrateSidebarOrderState", () => {
it("preserves legacy workspace ordering across hosts", () => {
it("discards legacy workspace ordering while retaining project ordering", () => {
const migrated = migrateSidebarOrderState({
projectOrderByServerId: {
"host-a": ["project-a"],
@@ -21,7 +21,6 @@ describe("migrateSidebarOrderState", () => {
expect(migrated).toEqual({
projectOrder: ["project-a"],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { "project-a": ["host-a:main", "host-a:feature", "host-b:main"] },
});
});
@@ -89,18 +88,14 @@ describe("sidebar order keys that end in whitespace", () => {
});
});
it("preserves project, pinned and ordinary workspace order", () => {
it("preserves project and pinned order while discarding 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"],
workspaceOrderByProject: { a: ["h:old", "h:new"] },
});
).toEqual({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"] });
});
it("preserves whitespace in current project and pin identities during upgrade", () => {
@@ -110,37 +105,5 @@ it("preserves whitespace in current project and pin identities during upgrade",
pinnedWorkspaceOrder: ["host:workspace "],
workspaceOrderByProject: {},
}),
).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 "] });
).toEqual({ projectOrder: ["project "], pinnedWorkspaceOrder: ["host:workspace "] });
});
+3 -71
View File
@@ -7,13 +7,10 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora
interface SidebarOrderStoreState {
projectOrder: string[];
pinnedWorkspaceOrder: string[];
workspaceOrderByProject: Record<string, string[]>;
getProjectOrder: () => string[];
setProjectOrder: (keys: string[]) => void;
getPinnedWorkspaceOrder: () => string[];
setPinnedWorkspaceOrder: (keys: string[]) => void;
getWorkspaceOrder: (projectViewKey: string) => string[];
setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void;
}
interface SidebarOrderPersistedState {
@@ -28,16 +25,12 @@ 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[] {
@@ -78,42 +71,13 @@ function dedupeKeys(keys: string[]): string[] {
return deduped;
}
function normalizeWorkspaceOrderByProject(
workspaceOrderByProject: Record<string, string[]> | undefined,
): Record<string, string[]> {
const normalized: Record<string, string[]> = {};
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<string, string[]>;
} {
const result = SidebarOrderPersistedStateSchema.safeParse(persistedState);
if (!result.success) {
return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} };
return { projectOrder: [], pinnedWorkspaceOrder: [] };
}
const state: SidebarOrderPersistedState = result.data;
@@ -127,26 +91,9 @@ 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,
};
}
@@ -155,7 +102,6 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
(set, get) => ({
projectOrder: [],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: {},
getProjectOrder: () => get().projectOrder,
setProjectOrder: (keys) => {
set({ projectOrder: dedupeKeys(keys) });
@@ -164,19 +110,6 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
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",
@@ -184,9 +117,8 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
partialize: (state) => ({
projectOrder: state.projectOrder,
pinnedWorkspaceOrder: state.pinnedWorkspaceOrder,
workspaceOrderByProject: state.workspaceOrderByProject,
}),
version: 3,
version: 2,
migrate: migrateSidebarOrderState,
},
),
@@ -300,7 +300,3 @@ 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");
});
@@ -91,7 +91,7 @@ const SidebarLabelFilterSchema = z.object({
labels: z.array(z.string()),
});
const SidebarViewPersistedStateSchema = z.strictObject({
workspaceSort: z.enum(["manual", "created", "lastPrompt"]).optional(),
workspaceSort: z.enum(["created", "lastPrompt"]).optional(),
showBackground: z.boolean().optional(),
groupMode: PersistedSidebarGroupModeSchema.optional(),
hostFilters: z.array(z.string()).optional(),
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/server": "0.13.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -72,8 +72,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@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",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+6 -23
View File
@@ -101,29 +101,12 @@ describe("plugin SDK import boundaries", () => {
);
});
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 <Text>{revealed}</Text>;
}
`),
).toEqual([]);
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(Object.entries(entries))(
@@ -1,6 +1,3 @@
// 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;
@@ -374,19 +374,13 @@ 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 another kind, a tool call, a plan, or a turn boundary
* ends it (#4699).
* item; a chunk of the other kind, a tool call, a plan, a user message,
* or a turn boundary ends it (#4699).
*/
private readonly fallbackChunkIds = new Map<
"agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk",
"agent_message_chunk" | "agent_thought_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;
@@ -505,23 +499,15 @@ class AcpRuntime {
};
let response: Pick<NewSessionResponse, "modes" | "configOptions">;
if (nativeSessionId) {
// 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.
response = await this.call(
this.connection.loadSession({
sessionId: nativeSessionId,
cwd: input.config.cwd,
mcpServers,
_meta: metadata,
}),
);
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({
@@ -912,14 +898,6 @@ 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);
}
@@ -933,14 +911,13 @@ class AcpRuntime {
this.fallbackChunkIds.clear();
return;
}
// 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.
// 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.
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}`;
@@ -968,31 +945,8 @@ 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<SessionUpdate, { sessionUpdate: "user_message_chunk" }>,
): 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" | "user_message_chunk",
kind: "agent_message_chunk" | "agent_thought_chunk",
messageId: string | null | undefined,
): string {
if (messageId) {
-184
View File
@@ -1500,187 +1500,3 @@ 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<Record<string, string>> {
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();
}
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/highlight": "0.13.0-beta.1",
"@getpaseo/plugin": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -1,4 +1,4 @@
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
@@ -7,9 +7,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { withClaudeReplayHistory } from "./transcript-history.js";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import type { ClaudeQueryFactory } from "./query.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
/**
* A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline.
@@ -32,18 +29,19 @@ describe("ClaudeAgentSession persisted history load", () => {
let configDir: string;
let records: LogRecord[];
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
async function resume(): Promise<void> {
const client = new ClaudeAgentClient({
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
queryFactory:
queryFactory ??
(() => {
throw new Error("history load must not start a query");
}),
resolveBinary: async () => process.execPath,
queryFactory: vi.fn(() => {
throw new Error("history load must not start a query");
}),
resolveVersion: async () => "2.1.220",
});
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
const session = await client.resumeSession(
{ provider: "claude", sessionId: SESSION_ID },
{ cwd },
);
await session.close();
}
function transcriptPath(): string {
@@ -60,218 +58,12 @@ describe("ClaudeAgentSession persisted history load", () => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
rmSync(tempRoot, { recursive: true, force: true });
});
test.each(["file_path", "files"])(
"replays %s without probing targets and preserves live existence checks",
async (field) => {
const existingPath = path.join(cwd, "existing.txt");
const missingPath = path.join(cwd, "missing.txt");
writeFileSync(existingPath, "existing");
const targets = [existingPath, missingPath];
const inputs = targets.map((target) => ({
[field]: field === "files" ? [target] : target,
}));
const toolBlocks = inputs.map((input, index) => ({
type: "tool_use",
id: `change-${index}`,
name: "FileChange",
input,
}));
const resultBlocks = toolBlocks.map((block) => ({
type: "tool_result",
tool_use_id: block.id,
content: "done",
}));
const transcript = [
{ type: "user", message: { content: "Change files" } },
{ type: "assistant", message: { content: toolBlocks } },
{ type: "user", message: { content: resultBlocks } },
{ type: "assistant", message: { content: "Finished" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), transcript.map((entry) => JSON.stringify(entry)).join("\n"));
// Observe the real filesystem calls; neither existing nor missing targets are mocked.
const exists = vi.spyOn(fs, "existsSync");
const liveMessages = [
{ type: "assistant", message: { content: toolBlocks } },
{ type: "user", message: { content: resultBlocks } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
const queryFactory = vi.fn();
queryFactory.mockReturnValue({
async next() {
const value = liveMessages.shift();
return value ? { done: false, value } : { done: true, value: undefined };
},
close() {},
async return() {
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
});
const session = await resume(queryFactory);
try {
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) history.push(event);
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual(
[],
);
const running = inputs.map((input, index) => ({
type: "tool_call",
callId: `change-${index}`,
name: "FileChange",
status: "running",
error: null,
detail: { type: "unknown", input, output: null },
}));
const completed = inputs.map((input, index) => ({
type: "tool_call",
callId: `change-${index}`,
name: "FileChange",
status: "completed",
error: null,
detail: {
type: "unknown",
input,
output: {
output: "done",
files: [{ path: index === 0 ? "existing.txt" : "missing.txt", kind: "update" }],
},
},
}));
expect(history).toEqual(
[
{ type: "user_message", text: "Change files" },
...running,
...completed,
{ type: "assistant_message", text: "Finished" },
].map((item) => ({ type: "timeline", provider: "claude", timestamp: undefined, item })),
);
exists.mockClear();
const live = await collectSessionTurnEvents(session, "Change files again");
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual([
[existingPath],
[missingPath],
]);
const liveCompleted = live.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "completed"
? [event.item]
: [],
);
const missing = completed[1];
expect(liveCompleted).toEqual([
completed[0],
{
...missing,
detail: {
...missing.detail,
output: { output: "done", files: [{ path: "missing.txt", kind: "add" }] },
},
},
]);
} finally {
await session.close();
}
},
);
test.each(["parent", "subagent"])(
"replays %s Write and Edit calls without probing their targets",
async (scope) => {
const filePath = path.join(cwd, "unavailable", "file.txt");
const content = ["Write", "Edit"].map((name) => ({
type: "tool_use",
id: name,
name,
input: { file_path: filePath },
}));
const entry = { type: "assistant", message: { content } };
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
if (scope === "subagent") {
const directory = path.join(path.dirname(transcriptPath()), SESSION_ID, "subagents");
mkdirSync(directory, { recursive: true });
writeFileSync(
path.join(directory, "agent-child.meta.json"),
JSON.stringify({ toolUseId: "task" }),
);
writeFileSync(
path.join(directory, "agent-child.jsonl"),
JSON.stringify({
...entry,
isSidechain: true,
agentId: "child",
}),
);
writeFileSync(
transcriptPath(),
JSON.stringify({
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "task",
name: "Task",
input: { description: "Change files" },
},
],
},
}),
);
} else {
writeFileSync(transcriptPath(), JSON.stringify(entry));
}
const exists = vi.spyOn(fs, "existsSync");
const session = await resume();
try {
const items = [];
for await (const event of session.streamHistory()) {
if (scope === "parent" && event.type === "timeline") items.push(event.item);
if (event.type === "provider_subagent" && event.event.type === "timeline") {
items.push(event.event.item);
}
}
expect(exists.mock.calls.filter(([target]) => target === filePath)).toEqual([]);
expect(items).toEqual([
{
type: "tool_call",
callId: "Write",
name: "Write",
status: "running",
error: null,
detail: { type: "write", filePath },
},
{
type: "tool_call",
callId: "Edit",
name: "Edit",
status: "running",
error: null,
detail: { type: "edit", filePath },
},
]);
} finally {
await session.close();
}
},
);
test("logs the resolved transcript path when the transcript does not exist", async () => {
await (await resume()).close();
await resume();
expect(records).toContainEqual(
expect.objectContaining({
@@ -287,7 +79,7 @@ describe("ClaudeAgentSession persisted history load", () => {
// A directory at the transcript path exists but cannot be read as a file.
mkdirSync(transcriptPath(), { recursive: true });
await (await resume()).close();
await resume();
expect(records).toContainEqual(
expect.objectContaining({
@@ -356,7 +148,7 @@ describe("ClaudeAgentSession persisted history load", () => {
chmodSync(path.join(configDir, "projects"), 0o000);
try {
await (await resume()).close();
await resume();
} finally {
chmodSync(path.join(configDir, "projects"), 0o755);
}
@@ -7,7 +7,6 @@ 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;
@@ -138,100 +137,4 @@ 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 =
"<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>";
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) =>
`<task-notification>\n<task-id>monitor-1</task-id>\n<summary>Monitor event</summary>\n<event>${event}</event>\n</task-notification>`,
);
notifications.push(
"<task-notification>\n<task-id>monitor-1</task-id>\n<status>completed</status>\n<summary>Monitor stream ended</summary>\n</task-notification>",
);
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 =
"<task-notification>\n<task-id>monitor-repeat</task-id>\n<status>completed</status>\n<summary>Monitor event</summary>\n</task-notification>";
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",
]);
},
);
});
@@ -834,7 +834,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
.filter((item) => item.type === "tool_call" && item.name === "task_notification"),
).toEqual([
expect.objectContaining({
callId: expect.stringMatching(/^task_notification_[a-f0-9]+_1$/),
callId: "task_notification_workflow-notification",
status: "completed",
}),
]);
@@ -2326,7 +2326,6 @@ class ClaudeAgentSession implements AgentSession {
{ type: "provider_subagent" }
>[] = [];
private historyPending = false;
private replayingHistory = false;
private turnState: TurnState = "idle";
private nextTurnOrdinal = 1;
private cancelCurrentTurn: (() => void) | null = null;
@@ -5254,7 +5253,6 @@ class ClaudeAgentSession implements AgentSession {
private loadPersistedHistory(sessionId: string): void {
let historyPath: string | null = null;
this.replayingHistory = true;
try {
this.taskState.reset();
historyPath = this.resolveHistoryPath(sessionId);
@@ -5274,8 +5272,6 @@ class ClaudeAgentSession implements AgentSession {
{ err: error, sessionId, historyPath },
"Failed to load Claude history from transcript",
);
} finally {
this.replayingHistory = false;
}
}
@@ -6043,11 +6039,6 @@ class ClaudeAgentSession implements AgentSession {
}
private detectFileKind(filePath: string): string {
// A historical target may be on an unreachable mount; its current state says nothing
// about whether the past operation created or updated it.
if (this.replayingHistory) {
return "update";
}
try {
return fs.existsSync(filePath) ? "update" : "add";
} catch {
@@ -6322,7 +6313,6 @@ export function convertClaudeHistoryEntry(
const userTaskNotificationItem = mapTaskNotificationUserContentToToolCall({
content,
messageId: userMessageId,
notificationId: typeof entry.notificationId === "string" ? entry.notificationId : undefined,
});
if (userTaskNotificationItem) {
return [userTaskNotificationItem];
@@ -97,17 +97,4 @@ 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: "<task-notification><status>completed</status></task-notification>",
messageId,
}),
);
expect(items.map((item) => item?.callId)).toEqual([
"task_notification_message-1",
"task_notification_message-2",
]);
});
});
@@ -12,7 +12,6 @@ const OptionalNonEmptyTrimmedStringSchema = z.preprocess(
);
const TaskNotificationEnvelopeSchema = z.object({
notificationId: z.string().optional(),
messageId: z.string().nullable(),
taskId: z.string().nullable(),
toolUseId: z.string().nullable(),
@@ -36,9 +35,7 @@ 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(),
@@ -55,7 +52,6 @@ const TaskNotificationHistoryRecordSchema = z
const MapTaskNotificationUserContentToToolCallInputSchema = z.object({
content: z.unknown(),
messageId: z.string().optional().nullable(),
notificationId: z.string().optional(),
});
export type TaskNotificationEnvelope = z.infer<typeof TaskNotificationEnvelopeSchema>;
@@ -159,7 +155,6 @@ function parseTaskNotificationFromUserContent(
}
return TaskNotificationEnvelopeSchema.parse({
notificationId: parsedInput.data.notificationId,
messageId: toNonEmptyString(parsedInput.data.messageId),
taskId: readTaskNotificationTagValue({ text: rawText, tagName: "task-id" }),
toolUseId:
@@ -191,7 +186,6 @@ 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) ??
@@ -218,76 +212,25 @@ 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<T>(entries: Iterable<T>): Generator<T> {
const occurrences = new Map<string, Map<string, number>>();
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<string, number>();
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 <event>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]
@@ -1,4 +1,3 @@
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";
@@ -89,24 +88,21 @@ function indexedRecords(
): Iterable<TranscriptEntry> {
return {
*[Symbol.iterator]() {
yield* assignTaskNotificationReplayIds(readEntries());
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* readEntries(): Generator<TranscriptEntry> {
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 {
@@ -1,80 +0,0 @@
import type { SessionInfo } from "@opencode/client";
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import { SessionChildren } from "./children.js";
function setup() {
const harness = new V2Harness();
const child: SessionInfo = {
...harness.info,
id: "child",
parentID: "session",
title: "Check the worker model",
agent: "worker",
model: { providerID: "test", id: "worker-model", variant: "xhigh" },
tokens: { input: 10000, output: 3000, reasoning: 2000, cache: { read: 3000, write: 800 } },
};
harness.api.session.list = async (input) => ({
data: input?.parentID === "session" ? [child] : [],
cursor: {},
});
harness.api.session.get = async () => child;
const events: AgentStreamEvent[] = [];
const children = new SessionChildren({
client: () => harness.api,
id: "session",
emit: (event) => events.push(event),
reconcilePermissions: async () => {},
});
const upserts = () =>
events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
);
return { children, child, upserts };
}
describe("OpenCode v2 child presentation", () => {
test("restores agent, model, variant and all token categories in the child subtitle", async () => {
const { children, upserts } = setup();
await children.reconcile("session");
expect(upserts()).toEqual([
expect.objectContaining({
id: "child",
title: "Check the worker model",
parentSubagentId: null,
status: "completed",
subtitle: "worker · worker-model · Extra High · 18.8k tokens",
}),
]);
await children.reconcile("session");
expect(upserts()).toHaveLength(1);
});
test("publishes changed child facts on live events even when its timestamp is unchanged", async () => {
const { children, child, upserts } = setup();
await children.observe({ id: "created", created: 1, type: "session.created", data: child });
child.model = { providerID: "test", id: "next-model", variant: "low" };
child.tokens.output += 1200;
await children.observe({
id: "finished",
created: 2,
type: "session.execution.succeeded",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: { sessionID: "child" },
});
expect(upserts().map((event) => event.subtitle)).toEqual([
"worker · worker-model · Extra High · 18.8k tokens",
"worker · next-model · Low · 20k tokens",
]);
});
test("keeps available agent information before a child has a model or token usage", async () => {
const { children, child, upserts } = setup();
delete child.model;
child.tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } };
await children.reconcile("session");
expect(upserts()[0]).toMatchObject({ title: "Check the worker model", subtitle: "worker" });
});
});
@@ -7,7 +7,6 @@ import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js";
import { messages } from "./history.js";
import { buildOpenCodeSubagentSubtitle } from "../subagent-presentation.js";
import type { V2Api } from "./api.js";
interface ChildrenOptions {
@@ -66,23 +65,11 @@ export class SessionChildren {
if (active[info.id]) status = "running";
else if (info.outcome === "failed") status = "failed";
else if (info.outcome === "interrupted") status = "canceled";
const subtitle = buildOpenCodeSubagentSubtitle({
agentName: info.agent,
modelId: info.model?.id,
variant: info.model?.variant,
totalTokens:
info.tokens.input +
info.tokens.output +
info.tokens.reasoning +
info.tokens.cache.read +
info.tokens.cache.write,
});
const presentation: ProviderSubagentInputEvent = {
type: "upsert",
id: info.id,
parentSubagentId: info.parentID === this.options.id ? null : info.parentID,
title: info.title ?? null,
...(subtitle ? { subtitle } : {}),
status,
cwd: info.location.directory,
timestamp: new Date(info.time.updated).toISOString(),
@@ -1,70 +0,0 @@
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<void>((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<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
@@ -1,21 +0,0 @@
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" },
]);
});
@@ -67,9 +67,7 @@ export class OpenAITTS implements TextToSpeechProvider {
| "pcm",
});
// 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 audioStream = response.body as unknown as Readable;
const duration = Date.now() - startTime;
this.logger.debug({ duration }, "Speech synthesis stream ready");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"private": true,
"type": "module",
"scripts": {
@@ -1,92 +0,0 @@
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<string, unknown>) {
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 });
}
}
+1 -27
View File
@@ -9,12 +9,10 @@ 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();
@@ -62,9 +60,6 @@ const CursorUsageResponseSchema = z.object({
bonusSpend: ApiNullableNumberSchema,
remaining: ApiNullableNumberSchema,
limit: ApiNullableNumberSchema,
totalPercentUsed: ApiNullableNumberSchema,
autoPercentUsed: ApiNullableNumberSchema,
apiPercentUsed: ApiNullableNumberSchema,
})
.nullish(),
billingCycleStart: CursorBillingCycleTimestampSchema,
@@ -182,29 +177,8 @@ 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);
@@ -223,7 +197,7 @@ export async function fetchUsage(
return {
status: "available",
planLabel: undefined,
windows,
windows: [],
balances,
details: [],
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.13.0",
"version": "0.13.0-beta.1",
"private": true,
"type": "module",
"scripts": {
+2 -3
View File
@@ -176,9 +176,8 @@ 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 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`.
Use `/client/ui` for all Paseo UI components and UI hooks. The previous entry remains an
undocumented compatibility alias until 2027-04-20.
For paced timeline text, replace `useRevealedText(text, phase)` and the surrounding `Text` with
`<StreamedText text={text} phase={phase} style={style} />` from `/client/ui`.