mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9496dcf22a |
+12
-24
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.13.0-beta.2 - 2026-10-23
|
||||
## 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,25 +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 under Display → Sort; workspaces no longer drag to reorder ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- 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))
|
||||
- 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))
|
||||
@@ -40,33 +33,28 @@ 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 still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474))
|
||||
- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||
- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630))
|
||||
- Fixed 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 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))
|
||||
|
||||
+4
-24
@@ -1,6 +1,6 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.13.0-beta.2 - 2026-10-23
|
||||
## 0.13.0-beta.1 - 2026-10-22
|
||||
|
||||
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
|
||||
|
||||
@@ -14,25 +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))
|
||||
- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#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))
|
||||
- 移除了旧版终端渲染器;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))
|
||||
@@ -42,31 +35,18 @@ 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`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474))
|
||||
- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||
- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630))
|
||||
- 修复了 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))
|
||||
- 修复了滚动时 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))
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
|
||||
sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.0-beta.2",
|
||||
"version": "0.13.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.13.0-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"version": "0.13.0-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.13.0-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@getpaseo/server": "0.13.0-beta.2",
|
||||
"@getpaseo/client": "0.13.0-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-beta.2",
|
||||
"version": "0.13.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@getpaseo/relay": "0.13.0-beta.2",
|
||||
"@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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@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-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/highlight": "0.13.0-beta.2",
|
||||
"@getpaseo/plugin": "0.13.0-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@getpaseo/relay": "0.13.0-beta.2",
|
||||
"@getpaseo/client": "0.13.0-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-beta.2",
|
||||
"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-beta.2",
|
||||
"version": "0.13.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.0-beta.2",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.0-beta.2",
|
||||
"version": "0.13.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -88,7 +88,6 @@ import {
|
||||
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
|
||||
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
|
||||
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
|
||||
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
type WorkspaceFileLocation,
|
||||
@@ -449,7 +448,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
|
||||
const inlinePathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
@@ -457,9 +455,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
openOutsideDirectory: openInlineOutsideDirectory,
|
||||
}),
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isActive) inlinePathOpener.cancel();
|
||||
|
||||
@@ -71,9 +71,6 @@ it("ignores an older probe after a newer file link opens", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
|
||||
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
|
||||
@@ -98,9 +95,6 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
|
||||
opener.cancel();
|
||||
@@ -108,25 +102,3 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
await pending;
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends folders outside the workspace away from the workspace Files panel", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
const workspaceFolders: string[] = [];
|
||||
const outsideFolders: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
client: null,
|
||||
openFile: () => {},
|
||||
openDirectory: (directory) => {
|
||||
workspaceFolders.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
outsideFolders.push(directory);
|
||||
},
|
||||
});
|
||||
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
|
||||
await opener.open({ raw, path: raw }, "preferred");
|
||||
}
|
||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||
expect(workspaceFolders).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
@@ -14,23 +14,12 @@ interface PathOpenInput {
|
||||
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
|
||||
}
|
||||
|
||||
type PathOpenTarget =
|
||||
| { kind: "file" }
|
||||
| { kind: "directory"; directoryPath: string }
|
||||
| { kind: "outsideDirectory"; directoryPath: string };
|
||||
|
||||
// Workspace paths arrive relative to the root; anything still absolute or
|
||||
// home-relative lives outside it, where the Files panel cannot list it.
|
||||
function isOutsideWorkspacePath(pathValue: string): boolean {
|
||||
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
|
||||
}
|
||||
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
|
||||
|
||||
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
|
||||
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
|
||||
if (!input.file) {
|
||||
return isOutsideWorkspacePath(input.directory)
|
||||
? { kind: "outsideDirectory", directoryPath: input.directory }
|
||||
: { kind: "directory", directoryPath: input.directory };
|
||||
return { kind: "directory", directoryPath: input.directory };
|
||||
}
|
||||
if (
|
||||
input.lineStart !== undefined ||
|
||||
@@ -53,7 +42,6 @@ export function createInlinePathOpener(input: {
|
||||
client: PathOpenInput["client"];
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
openOutsideDirectory: (path: string) => void;
|
||||
}) {
|
||||
let generation = 0;
|
||||
return {
|
||||
@@ -70,8 +58,6 @@ export function createInlinePathOpener(input: {
|
||||
if (ownGeneration !== generation) return;
|
||||
if (destination.kind === "directory") {
|
||||
input.openDirectory(destination.directoryPath);
|
||||
} else if (destination.kind === "outsideDirectory") {
|
||||
input.openOutsideDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -67,7 +67,6 @@ export const ar: TranslationResources = {
|
||||
daemonClientUnavailable: "عميل Daemon غير متوفر",
|
||||
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
|
||||
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
|
||||
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
|
||||
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -63,7 +63,6 @@ export const en = {
|
||||
daemonClientUnavailable: "Daemon client unavailable",
|
||||
daemonClientDisconnected: "Daemon client is disconnected",
|
||||
noFileFound: "No file found for {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} is outside this workspace",
|
||||
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,7 +68,6 @@ export const es: TranslationResources = {
|
||||
daemonClientUnavailable: "ClienteDaemonno disponible",
|
||||
daemonClientDisconnected: "El clienteDaemonestá desconectado",
|
||||
noFileFound: "No se encontró ningún archivo para{{token}}",
|
||||
folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo",
|
||||
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,7 +68,6 @@ export const fr: TranslationResources = {
|
||||
daemonClientUnavailable: "Client du daemon indisponible",
|
||||
daemonClientDisconnected: "Le client du daemon est déconnecté",
|
||||
noFileFound: "Aucun fichier trouvé pour {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail",
|
||||
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,7 +68,6 @@ export const ja: TranslationResources = {
|
||||
daemonClientUnavailable: "デーモンクライアントが利用できません",
|
||||
daemonClientDisconnected: "デーモンクライアントが切断されています",
|
||||
noFileFound: "{{token}}のファイルが見つかりません",
|
||||
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
|
||||
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -67,7 +67,6 @@ export const ko: TranslationResources = {
|
||||
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
|
||||
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
|
||||
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
|
||||
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
|
||||
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,7 +68,6 @@ export const ptBR: TranslationResources = {
|
||||
daemonClientUnavailable: "Cliente do daemon indisponível",
|
||||
daemonClientDisconnected: "O cliente do daemon está desconectado",
|
||||
noFileFound: "Nenhum arquivo encontrado para {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} está fora deste workspace",
|
||||
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,7 +68,6 @@ export const ru: TranslationResources = {
|
||||
daemonClientUnavailable: "Daemon клиента недоступен",
|
||||
daemonClientDisconnected: "Daemon клиента отключен",
|
||||
noFileFound: "Файл для {{token}} не найден",
|
||||
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
|
||||
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -67,7 +67,6 @@ export const zhCN: TranslationResources = {
|
||||
daemonClientUnavailable: "Daemon client 不可用",
|
||||
daemonClientDisconnected: "Daemon client 已断开连接",
|
||||
noFileFound: "未找到 {{token}} 对应的文件",
|
||||
folderOutsideWorkspace: "{{path}} 不在此工作区内",
|
||||
unexpectedDictationError: "处理听写时发生意外错误。",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -63,7 +63,6 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
@@ -3682,7 +3681,6 @@ function WorkspaceScreenContent({
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId);
|
||||
const markdownPathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
@@ -3690,15 +3688,8 @@ function WorkspaceScreenContent({
|
||||
client,
|
||||
openFile: openMarkdownFile,
|
||||
openDirectory: openMarkdownDirectory,
|
||||
openOutsideDirectory: openMarkdownOutsideDirectory,
|
||||
}),
|
||||
[
|
||||
workspaceDirectory,
|
||||
client,
|
||||
openMarkdownFile,
|
||||
openMarkdownDirectory,
|
||||
openMarkdownOutsideDirectory,
|
||||
],
|
||||
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused) markdownPathOpener.cancel();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||
|
||||
/**
|
||||
* Opens a folder outside the workspace in the system file manager. The Files
|
||||
* panel only lists the workspace, so hosts without a local file manager get a
|
||||
* toast instead.
|
||||
*/
|
||||
export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isLocalExecution = useIsLocalDaemon(serverId);
|
||||
const { targets } = useDesktopOpenTargets({ isLocalExecution });
|
||||
const fileManagerTarget = targets.find((target) => target.kind === "file-manager");
|
||||
|
||||
return useCallback(
|
||||
(folderPath: string) => {
|
||||
const showOutsideWorkspace = () =>
|
||||
toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath }));
|
||||
if (!fileManagerTarget) {
|
||||
showOutsideWorkspace();
|
||||
return;
|
||||
}
|
||||
void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch(
|
||||
(error: unknown) => {
|
||||
console.warn("[open-outside-workspace-folder] open failed", error);
|
||||
showOutsideWorkspace();
|
||||
},
|
||||
);
|
||||
},
|
||||
[fileManagerTarget, t, toast],
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.13.0-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@getpaseo/server": "0.13.0-beta.2",
|
||||
"@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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.13.0-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/relay": "0.13.0-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.1",
|
||||
"@getpaseo/relay": "0.13.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.13.0-beta.2",
|
||||
"version": "0.13.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -51,10 +51,6 @@ class FakeEditorTargets implements EditorTargetRuntime {
|
||||
return this.paths.has(targetPath);
|
||||
}
|
||||
|
||||
expandHomePath(targetPath: string): string {
|
||||
return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath;
|
||||
}
|
||||
|
||||
isAbsolutePath(targetPath: string): boolean {
|
||||
return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath);
|
||||
}
|
||||
@@ -313,19 +309,6 @@ describe("editor target registry", () => {
|
||||
expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]);
|
||||
});
|
||||
|
||||
it("opens a home-relative folder in the file manager", async () => {
|
||||
const runtime = new FakeEditorTargets();
|
||||
runtime.addPath("/home/me/Downloads/contracts");
|
||||
|
||||
await openEditorTarget(
|
||||
{ editorId: "file-manager", workspacePath: "~/Downloads/contracts" },
|
||||
runtime,
|
||||
[fileManagerTarget],
|
||||
);
|
||||
|
||||
expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]);
|
||||
});
|
||||
|
||||
it("keeps the platform file-manager ids used by stored preferences", async () => {
|
||||
const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [
|
||||
finderTarget,
|
||||
|
||||
@@ -77,16 +77,10 @@ export function getEditorTarget(
|
||||
}
|
||||
|
||||
export async function openEditorTarget(
|
||||
request: EditorTargetLaunchInput & { editorId: string },
|
||||
input: EditorTargetLaunchInput & { editorId: string },
|
||||
runtime: EditorTargetRuntime,
|
||||
targets: readonly EditorTarget[] = EDITOR_TARGETS,
|
||||
): Promise<void> {
|
||||
// Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard.
|
||||
const input = {
|
||||
...request,
|
||||
workspacePath: runtime.expandHomePath(request.workspacePath),
|
||||
filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath),
|
||||
};
|
||||
if (!runtime.isAbsolutePath(input.workspacePath)) {
|
||||
throw new Error("Editor target workspace path must be an absolute local path");
|
||||
}
|
||||
|
||||
@@ -56,14 +56,6 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
|
||||
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
|
||||
}
|
||||
|
||||
function expandHomePath(value: string, homeDirectory: string): string {
|
||||
if (value === "~") return homeDirectory;
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
||||
return path.join(homeDirectory, value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveExecutable(
|
||||
commands: readonly string[],
|
||||
input: {
|
||||
@@ -148,7 +140,6 @@ export function createEditorTargetRuntime(
|
||||
env,
|
||||
pathExists,
|
||||
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
|
||||
expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory),
|
||||
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
|
||||
async spawnDetached({ command, args }) {
|
||||
const commandScript = isWindowsCommandScript(command, platform);
|
||||
|
||||
@@ -24,8 +24,6 @@ export interface EditorTargetRuntime {
|
||||
|
||||
pathExists(path: string): boolean;
|
||||
isAbsolutePath(path: string): boolean;
|
||||
/** Resolves a leading `~` to the user's home directory. */
|
||||
expandHomePath(path: string): string;
|
||||
resolveCommand(commands: readonly string[]): string | null;
|
||||
spawnDetached(input: { command: string; args: readonly string[] }): Promise<void>;
|
||||
openPath(path: string): Promise<void>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.13.0-beta.2",
|
||||
"version": "0.13.0-beta.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.13.0-beta.2",
|
||||
"version": "0.13.0-beta.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.13.0-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@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-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.13.0-beta.2",
|
||||
"version": "0.13.0-beta.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.13.0-beta.2",
|
||||
"version": "0.13.0-beta.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.13.0-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/highlight": "0.13.0-beta.2",
|
||||
"@getpaseo/plugin": "0.13.0-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@getpaseo/relay": "0.13.0-beta.2",
|
||||
"@getpaseo/client": "0.13.0-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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.13.0-beta.2",
|
||||
"version": "0.13.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.13.0-beta.2",
|
||||
"version": "0.13.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user