mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f71591d5a3 | ||
|
|
d5a83362a7 | ||
|
|
d0fd2e5320 | ||
|
|
5dc350034e | ||
|
|
4845e1da58 | ||
|
|
72e3316f4f | ||
|
|
566218d682 | ||
|
|
b8bb335c56 | ||
|
|
e01a38d943 | ||
|
|
d34d17a907 | ||
|
|
6d655ac1fe | ||
|
|
a54cc6dd11 | ||
|
|
c1b257b8f4 | ||
|
|
f3d74b8575 |
+11
-4
@@ -1,6 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.13.0-beta.2 - 2026-10-23
|
## 0.13.0 - 2026-10-24
|
||||||
|
|
||||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ paseo host add 'relay://...' --label laptop
|
|||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Changed sidebar workspaces to sort by Created, with Last prompt under Display → Sort; workspaces no longer drag to reorder ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
|
||||||
- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
- Changed 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 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 Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611))
|
||||||
@@ -32,6 +32,7 @@ paseo host add 'relay://...' --label laptop
|
|||||||
- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
- Changed 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))
|
- 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))
|
- 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))
|
- 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 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))
|
- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
||||||
@@ -48,10 +49,11 @@ paseo host add 'relay://...' --label laptop
|
|||||||
- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
- Added 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 `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))
|
- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368))
|
||||||
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474))
|
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
|
||||||
- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
- 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))
|
- 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 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 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
|
||||||
@@ -66,6 +68,11 @@ paseo host add 'relay://...' --label laptop
|
|||||||
- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681))
|
- Fixed 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 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 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 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 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 saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
|
||||||
@@ -73,7 +80,7 @@ paseo host add 'relay://...' --label laptop
|
|||||||
- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
|
- Fixed 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 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 the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||||
- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429))
|
- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
|
||||||
- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362))
|
- Fixed 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 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))
|
- 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))
|
||||||
|
|||||||
+11
-4
@@ -1,6 +1,6 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
## 0.13.0-beta.2 - 2026-10-23
|
## 0.13.0 - 2026-10-24
|
||||||
|
|
||||||
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
|
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ paseo host add 'relay://...' --label laptop
|
|||||||
|
|
||||||
### 改进
|
### 改进
|
||||||
|
|
||||||
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”;工作区不再支持拖动排序 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
|
||||||
- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||||
- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690))
|
- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690))
|
||||||
- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611))
|
- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611))
|
||||||
@@ -32,6 +32,7 @@ paseo host add 'relay://...' --label laptop
|
|||||||
- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
- 手机上的 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))
|
- 如果 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))
|
- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#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))
|
- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639))
|
||||||
- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
|
- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
|
||||||
- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
||||||
@@ -48,10 +49,11 @@ paseo host add 'relay://...' --label laptop
|
|||||||
- 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
- 新增插件列表翻译,可通过 `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))
|
- `@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))
|
- 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368))
|
||||||
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474))
|
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口(包括 `useRevealedText`)仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
|
||||||
- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
- 弃用面板的 `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))
|
- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||||
- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630))
|
- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#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))
|
- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
@@ -66,6 +68,11 @@ paseo host add 'relay://...' --label laptop
|
|||||||
- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681))
|
- 修复了没有应用显示工作区时,工作区 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))
|
- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
|
||||||
- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608))
|
- 修复了在手机上重新打开标记为未读的工作区后 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))
|
- 修复了滚动时 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))
|
- 修复了导入或恢复的聊天中 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))
|
- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547))
|
||||||
@@ -73,7 +80,7 @@ paseo host add 'relay://...' --label laptop
|
|||||||
- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
|
- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#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))
|
- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||||
- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||||
- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429))
|
- 修复了 Android 上不受信任的证书显示 "Host not found"、无法解析的主机名显示 TLS 建议的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
|
||||||
- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362))
|
- 修复了 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))
|
- 修复了紧凑 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))
|
- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365))
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||||
|
|
||||||
|
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||||
|
|
||||||
|
`sh
|
||||||
|
|
||||||
|
paseo host add 192.168.1.20:6767 --label studio
|
||||||
|
|
||||||
|
paseo host add 'ssh://me@build-box' --label build-box
|
||||||
|
|
||||||
|
paseo host add 'relay://...' --label laptop
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||||
|
|
||||||
|
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||||
|
|
||||||
|
`sh
|
||||||
|
|
||||||
|
paseo host add 192.168.1.20:6767 --label studio
|
||||||
|
|
||||||
|
paseo host add 'ssh://me@build-box' --label build-box
|
||||||
|
|
||||||
|
paseo host add 'relay://...' --label laptop
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||||
|
|
||||||
|
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||||
|
|
||||||
|
`sh
|
||||||
|
|
||||||
|
paseo host add 192.168.1.20:6767 --label studio
|
||||||
|
|
||||||
|
paseo host add 'ssh://me@build-box' --label build-box
|
||||||
|
|
||||||
|
paseo host add 'relay://...' --label laptop
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||||
|
|
||||||
|
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||||
|
|
||||||
|
`sh
|
||||||
|
|
||||||
|
paseo host add 192.168.1.20:6767 --label studio
|
||||||
|
|
||||||
|
paseo host add 'ssh://me@build-box' --label build-box
|
||||||
|
|
||||||
|
paseo host add 'relay://...' --label laptop
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
Full notes: https://paseo.sh/changelog
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
|
sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
|
||||||
|
|||||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -35695,7 +35695,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -37961,12 +37961,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.13.0-beta.2",
|
"@getpaseo/client": "0.13.0",
|
||||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
"@getpaseo/protocol": "0.13.0",
|
||||||
"@getpaseo/server": "0.13.0-beta.2",
|
"@getpaseo/server": "0.13.0",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -38213,10 +38213,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
"@getpaseo/protocol": "0.13.0",
|
||||||
"@getpaseo/relay": "0.13.0-beta.2",
|
"@getpaseo/relay": "0.13.0",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -38228,7 +38228,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -38472,7 +38472,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -42124,7 +42124,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -42153,15 +42153,15 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^1.4.0",
|
"@agentclientprotocol/sdk": "^1.4.0",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.13.0-beta.2",
|
"@getpaseo/client": "0.13.0",
|
||||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
"@getpaseo/protocol": "0.13.0",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -42171,8 +42171,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.13.0-beta.2",
|
"@getpaseo/client": "0.13.0",
|
||||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
"@getpaseo/protocol": "0.13.0",
|
||||||
"react": ">=19.1.0 <20",
|
"react": ">=19.1.0 <20",
|
||||||
"react-native": ">=0.81.5",
|
"react-native": ">=0.81.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -42194,7 +42194,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -42221,7 +42221,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ciphers": "^2.4.0",
|
"@noble/ciphers": "^2.4.0",
|
||||||
"@noble/curves": "^2.4.0",
|
"@noble/curves": "^2.4.0",
|
||||||
@@ -42442,16 +42442,16 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.13.0-beta.2",
|
"@getpaseo/client": "0.13.0",
|
||||||
"@getpaseo/highlight": "0.13.0-beta.2",
|
"@getpaseo/highlight": "0.13.0",
|
||||||
"@getpaseo/plugin": "0.13.0-beta.2",
|
"@getpaseo/plugin": "0.13.0",
|
||||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
"@getpaseo/protocol": "0.13.0",
|
||||||
"@getpaseo/relay": "0.13.0-beta.2",
|
"@getpaseo/relay": "0.13.0",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -43372,7 +43372,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
@@ -44982,7 +44982,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"name": "@getpaseo/builtin-plugins",
|
"name": "@getpaseo/builtin-plugins",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/plugin": "*",
|
"@getpaseo/plugin": "*",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -229,6 +229,143 @@ test("captures the desktop import sheet and command-center entry", async ({ page
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("large import lists keep mounted rows bounded and remain scrollable", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture");
|
||||||
|
await mkdir(fixtureDirectory, { recursive: true });
|
||||||
|
try {
|
||||||
|
const newest = Date.now();
|
||||||
|
for (let index = 0; index < 200; index += 1) {
|
||||||
|
const id = `virtualized-${String(index).padStart(3, "0")}`;
|
||||||
|
const file = path.join(fixtureDirectory, `${id}.jsonl`);
|
||||||
|
await writeFile(
|
||||||
|
file,
|
||||||
|
`${JSON.stringify({
|
||||||
|
type: "user",
|
||||||
|
sessionId: id,
|
||||||
|
cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory,
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`,
|
||||||
|
},
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
const timestamp = new Date(newest - index * 10);
|
||||||
|
await utimes(file, timestamp, timestamp);
|
||||||
|
}
|
||||||
|
for (const width of [1280, 390]) {
|
||||||
|
const flow = new ImportSessionFlow(page);
|
||||||
|
await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 });
|
||||||
|
await flow.openGlobally();
|
||||||
|
const rows = page.locator('[data-testid^="import-session-session-"]');
|
||||||
|
const scrollToEnd = async (end = true) => {
|
||||||
|
// Scroll the body's real overflow element. Virtual rows need not exist yet.
|
||||||
|
await rows.first().evaluate((row, atEnd) => {
|
||||||
|
let element = row.parentElement;
|
||||||
|
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
|
||||||
|
element = element.parentElement;
|
||||||
|
}
|
||||||
|
if (!element) throw new Error("Import list has no scroll owner");
|
||||||
|
element.scrollTop = atEnd ? element.scrollHeight : 0;
|
||||||
|
element.dispatchEvent(new Event("scroll"));
|
||||||
|
}, end);
|
||||||
|
};
|
||||||
|
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
|
||||||
|
const loadMore = page.getByTestId("import-session-load-more");
|
||||||
|
await expect(async () => {
|
||||||
|
await scrollToEnd();
|
||||||
|
await expect(loadMore).toBeInViewport({ timeout: 500 });
|
||||||
|
}).toPass({ timeout: 10_000 });
|
||||||
|
await loadMore.click();
|
||||||
|
if (pageIndex < 2) await expect(loadMore).toBeEnabled();
|
||||||
|
}
|
||||||
|
await expect(async () => {
|
||||||
|
await scrollToEnd();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("import-session-session-claude-virtualized-199"),
|
||||||
|
).toBeInViewport({ timeout: 500 });
|
||||||
|
}).toPass({ timeout: 10_000 });
|
||||||
|
await expect.poll(() => rows.count()).toBeLessThan(100);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) });
|
||||||
|
if (width === 1280) {
|
||||||
|
const row = (index: number) =>
|
||||||
|
page.getByTestId(
|
||||||
|
`import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`,
|
||||||
|
);
|
||||||
|
await expect(async () => {
|
||||||
|
await scrollToEnd(false);
|
||||||
|
await expect(row(0)).toBeInViewport({ timeout: 500 });
|
||||||
|
}).toPass({ timeout: 10_000 });
|
||||||
|
await row(0).focus();
|
||||||
|
for (let index = 1; index <= 30; index += 1) {
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await expect(row(index)).toBeFocused();
|
||||||
|
}
|
||||||
|
for (let index = 29; index >= 15; index -= 1) {
|
||||||
|
await page.keyboard.press("Shift+Tab");
|
||||||
|
await expect(row(index)).toBeFocused();
|
||||||
|
}
|
||||||
|
const list = page.getByTestId("import-session-list");
|
||||||
|
const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => {
|
||||||
|
const listener = await list.evaluateHandle((node, eventName) => {
|
||||||
|
const promise = new Promise<void>((resolve) => {
|
||||||
|
node.addEventListener(eventName, () => resolve(), { once: true });
|
||||||
|
});
|
||||||
|
return { promise };
|
||||||
|
}, event);
|
||||||
|
try {
|
||||||
|
await page.keyboard.press(key);
|
||||||
|
await listener.evaluate((value) => value.promise);
|
||||||
|
} finally {
|
||||||
|
await listener.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const beforePageDown = await list.evaluate((node) => node.scrollTop);
|
||||||
|
await scrollWithKey("scroll", "PageDown");
|
||||||
|
await expect
|
||||||
|
.poll(() => list.evaluate((node) => node.scrollTop))
|
||||||
|
.toBeGreaterThan(beforePageDown);
|
||||||
|
await expect(row(15)).toBeFocused();
|
||||||
|
await page.keyboard.press("End");
|
||||||
|
await expect(row(15)).toBeFocused();
|
||||||
|
await expect(row(199)).toBeInViewport();
|
||||||
|
// Home must remain at the first row after the native page scroll ends.
|
||||||
|
await scrollWithKey("scrollend", "Home");
|
||||||
|
await expect(row(0)).toBeInViewport();
|
||||||
|
await expect(row(15)).toBeFocused();
|
||||||
|
|
||||||
|
await page.keyboard.press("End");
|
||||||
|
await expect(row(199)).toBeInViewport();
|
||||||
|
await row(199).focus();
|
||||||
|
await scrollWithKey("scroll", "PageUp");
|
||||||
|
await page.keyboard.press("Home");
|
||||||
|
await expect(row(0)).toBeInViewport();
|
||||||
|
await scrollWithKey("scrollend", "End");
|
||||||
|
await expect(row(199)).toBeInViewport();
|
||||||
|
await expect(row(199)).toBeFocused();
|
||||||
|
await testInfo.attach("keyboard-scroll-owner", {
|
||||||
|
body: JSON.stringify(
|
||||||
|
await list.evaluate((node) => ({
|
||||||
|
focusedRow: document.activeElement?.getAttribute("data-testid"),
|
||||||
|
scrollTop: node.scrollTop,
|
||||||
|
scrollHeight: node.scrollHeight,
|
||||||
|
clientHeight: node.clientHeight,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") });
|
||||||
|
await page.keyboard.press("Home");
|
||||||
|
await expect(row(0)).toBeInViewport();
|
||||||
|
}
|
||||||
|
await flow.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(fixtureDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||||
const outputPath = testInfo.outputPath(name);
|
const outputPath = testInfo.outputPath(name);
|
||||||
await page.screenshot({ path: outputPath, fullPage: true });
|
await page.screenshot({ path: outputPath, fullPage: true });
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import {
|
|||||||
} from "../support/helpers/settings";
|
} from "../support/helpers/settings";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
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(
|
async function openWorkspace(
|
||||||
page: import("@playwright/test").Page,
|
page: import("@playwright/test").Page,
|
||||||
@@ -53,6 +55,74 @@ async function openWorkspace(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Settings sidebar navigation", () => {
|
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 }) => {
|
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
@@ -147,6 +217,42 @@ test.describe("Settings sidebar navigation", () => {
|
|||||||
test.describe("Settings — compact master-detail", () => {
|
test.describe("Settings — compact master-detail", () => {
|
||||||
test.use({ viewport: { width: 390, height: 844 } });
|
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 ({
|
test("opens compact app and host details and returns through the settings list", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
|
|||||||
await page.keyboard.press(`${modifier}+Comma`);
|
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 {
|
interface ComposerFrame {
|
||||||
at: number;
|
at: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -27,7 +37,9 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page)
|
|||||||
|
|
||||||
const sample = () => {
|
const sample = () => {
|
||||||
if (capture.stopped) return;
|
if (capture.stopped) return;
|
||||||
const root = document.querySelector<HTMLElement>('[data-testid="message-input-root"]');
|
const root = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-testid="message-input-root"]'),
|
||||||
|
).find((candidate) => candidate.offsetParent !== null);
|
||||||
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
|
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
|
||||||
if (root && input) {
|
if (root && input) {
|
||||||
const style = getComputedStyle(input);
|
const style = getComputedStyle(input);
|
||||||
@@ -178,6 +190,8 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
|
|
||||||
|
await openComposerTab(page, firstAgent.id);
|
||||||
|
await openComposerTab(page, secondAgent.id);
|
||||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await input.fill("Keep this short draft in the composer");
|
await input.fill("Keep this short draft in the composer");
|
||||||
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||||
@@ -194,6 +208,7 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
|
|
||||||
const frames = await stopComposerFrameCapture(page);
|
const frames = await stopComposerFrameCapture(page);
|
||||||
const visibleFrames = frames.filter((frame) => frame.visible);
|
const visibleFrames = frames.filter((frame) => frame.visible);
|
||||||
|
expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0);
|
||||||
const tallestIndex = visibleFrames.reduce(
|
const tallestIndex = visibleFrames.reduce(
|
||||||
(index, frame, candidateIndex) =>
|
(index, frame, candidateIndex) =>
|
||||||
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
|
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class ImportSessionFlow {
|
|||||||
await expectMobileAgentSidebarVisible(this.page);
|
await expectMobileAgentSidebarVisible(this.page);
|
||||||
}
|
}
|
||||||
await openGlobalNewWorkspaceComposer(this.page);
|
await openGlobalNewWorkspaceComposer(this.page);
|
||||||
const button = this.page.getByTestId("new-workspace-import-session");
|
const button = this.page.getByTestId("new-workspace-import-session").filter({ visible: true });
|
||||||
await expect(button).toHaveAccessibleName("Import session");
|
await expect(button).toHaveAccessibleName("Import session");
|
||||||
await expect(button).toBeInViewport();
|
await expect(button).toBeInViewport();
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ export class ImportSessionFlow {
|
|||||||
await this.openFromNewWorkspace();
|
await this.openFromNewWorkspace();
|
||||||
}
|
}
|
||||||
async openFromNewWorkspace() {
|
async openFromNewWorkspace() {
|
||||||
await this.page.getByTestId("new-workspace-import-session").click();
|
await this.page.getByTestId("new-workspace-import-session").filter({ visible: true }).click();
|
||||||
await this.expectSheetReady();
|
await this.expectSheetReady();
|
||||||
}
|
}
|
||||||
async openFromWorkspaceHeader() {
|
async openFromWorkspaceHeader() {
|
||||||
@@ -86,7 +86,29 @@ export class ImportSessionFlow {
|
|||||||
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
|
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
|
||||||
}
|
}
|
||||||
async revealSession(id: string) {
|
async revealSession(id: string) {
|
||||||
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
|
const target = this.page.getByTestId(rowTestId(id));
|
||||||
|
if ((await target.count()) === 0) {
|
||||||
|
await this.scrollList(false);
|
||||||
|
await expect(async () => {
|
||||||
|
if ((await target.count()) === 0) await this.scrollList(true);
|
||||||
|
await expect(target).toHaveCount(1, { timeout: 100 });
|
||||||
|
}).toPass({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
await target.scrollIntoViewIfNeeded();
|
||||||
|
await expect(target).toBeInViewport();
|
||||||
|
}
|
||||||
|
private async scrollList(forward: boolean) {
|
||||||
|
await this.page
|
||||||
|
.locator('[data-testid^="import-session-session-"]')
|
||||||
|
.first()
|
||||||
|
.evaluate((row, advance) => {
|
||||||
|
let element = row.parentElement;
|
||||||
|
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY))
|
||||||
|
element = element.parentElement;
|
||||||
|
if (!element) throw new Error("Import list has no scroll owner");
|
||||||
|
element.scrollTop = advance ? element.scrollTop + element.clientHeight * 0.8 : 0;
|
||||||
|
element.dispatchEvent(new Event("scroll"));
|
||||||
|
}, forward);
|
||||||
}
|
}
|
||||||
async search(query: string) {
|
async search(query: string) {
|
||||||
await this.page.getByTestId("import-session-search").fill(query);
|
await this.page.getByTestId("import-session-search").fill(query);
|
||||||
@@ -100,7 +122,8 @@ export class ImportSessionFlow {
|
|||||||
}
|
}
|
||||||
async loadMore() {
|
async loadMore() {
|
||||||
await this.page.getByTestId("import-session-load-more").click();
|
await this.page.getByTestId("import-session-load-more").click();
|
||||||
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
|
await this.revealSession("fixture-root-20");
|
||||||
|
await expect(this.page.getByText("Root session 20", { exact: true })).toBeInViewport();
|
||||||
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
|
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
|
||||||
}
|
}
|
||||||
async retryProvider(provider: string, label: string) {
|
async retryProvider(provider: string, label: string) {
|
||||||
@@ -111,6 +134,7 @@ export class ImportSessionFlow {
|
|||||||
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
|
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
|
||||||
}
|
}
|
||||||
async importSession(id: string) {
|
async importSession(id: string) {
|
||||||
|
await this.revealSession(id);
|
||||||
await this.page.getByTestId(rowTestId(id)).click();
|
await this.page.getByTestId(rowTestId(id)).click();
|
||||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
|
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
@@ -138,7 +162,7 @@ export class ImportSessionFlow {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
async close() {
|
async close() {
|
||||||
await this.page.keyboard.press("Escape");
|
await this.page.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
|
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
|
||||||
}
|
}
|
||||||
async expectCommandCenterMatch() {
|
async expectCommandCenterMatch() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { ImportSessionListComponent } from "./import-session-list.types";
|
||||||
|
|
||||||
|
// Native keeps the sheet-owned ScrollView until its gestures and keyboard are verified.
|
||||||
|
export const ImportSessionList: ImportSessionListComponent | null = null;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactElement, ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface ImportSessionListProps<Item> {
|
||||||
|
data: ReadonlyArray<Item>;
|
||||||
|
keyExtractor: (item: Item) => string;
|
||||||
|
renderItem: (item: Item) => ReactNode;
|
||||||
|
header: ReactElement | null;
|
||||||
|
footer: ReactElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportSessionListComponent = <Item>(
|
||||||
|
props: ImportSessionListProps<Item>,
|
||||||
|
) => ReactElement;
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type FocusEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
|
} from "react";
|
||||||
|
import { View, type LayoutChangeEvent } from "react-native";
|
||||||
|
import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual";
|
||||||
|
import type { ImportSessionListProps } from "./import-session-list.types";
|
||||||
|
|
||||||
|
const SCROLLER_STYLE: CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
overflowX: "hidden",
|
||||||
|
overscrollBehavior: "contain",
|
||||||
|
};
|
||||||
|
const CONTENT_STYLE: CSSProperties = { width: "100%", position: "relative" };
|
||||||
|
|
||||||
|
export function ImportSessionList<Item>({
|
||||||
|
data,
|
||||||
|
keyExtractor,
|
||||||
|
renderItem,
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
}: ImportSessionListProps<Item>) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [headerHeight, setHeaderHeight] = useState(0);
|
||||||
|
const [focusedKey, setFocusedKey] = useState<string | null>(null);
|
||||||
|
const indexByKey = useMemo(
|
||||||
|
() => new Map(data.map((item, index) => [keyExtractor(item), index])),
|
||||||
|
[data, keyExtractor],
|
||||||
|
);
|
||||||
|
const focusedIndex = focusedKey === null ? undefined : indexByKey.get(focusedKey);
|
||||||
|
const rangeExtractor = useCallback(
|
||||||
|
(range: Range) => {
|
||||||
|
const visible = defaultRangeExtractor(range);
|
||||||
|
if (focusedIndex === undefined) return visible;
|
||||||
|
// Keep focus and its Tab neighbours mounted when PageDown/End moves the window.
|
||||||
|
const focused = [focusedIndex - 1, focusedIndex, focusedIndex + 1].filter(
|
||||||
|
(index) => index >= 0 && index < data.length,
|
||||||
|
);
|
||||||
|
return [...new Set([...visible, ...focused])].sort((left, right) => left - right);
|
||||||
|
},
|
||||||
|
[data.length, focusedIndex],
|
||||||
|
);
|
||||||
|
const handleFocus = useCallback((event: FocusEvent<HTMLDivElement>) => {
|
||||||
|
const row = event.target.closest<HTMLElement>("[data-session-key]");
|
||||||
|
setFocusedKey(row?.dataset.sessionKey ?? null);
|
||||||
|
}, []);
|
||||||
|
const handleBlur = useCallback((event: FocusEvent<HTMLDivElement>) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) setFocusedKey(null);
|
||||||
|
}, []);
|
||||||
|
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
setHeaderHeight(event.nativeEvent.layout.height);
|
||||||
|
}, []);
|
||||||
|
const getScrollElement = useCallback(() => scrollRef.current, []);
|
||||||
|
const getItemKey = useCallback(
|
||||||
|
(index: number) => keyExtractor(data[index]!),
|
||||||
|
[data, keyExtractor],
|
||||||
|
);
|
||||||
|
const estimateSize = useCallback(() => 88, []);
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: data.length,
|
||||||
|
getScrollElement,
|
||||||
|
getItemKey,
|
||||||
|
estimateSize,
|
||||||
|
scrollMargin: headerHeight,
|
||||||
|
overscan: 5,
|
||||||
|
rangeExtractor,
|
||||||
|
});
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (
|
||||||
|
event.defaultPrevented ||
|
||||||
|
!(target instanceof HTMLElement) ||
|
||||||
|
!event.currentTarget.contains(target) ||
|
||||||
|
target.isContentEditable ||
|
||||||
|
target.closest("input, textarea, select")
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
// Reconcile the landing position as variable-height rows are measured.
|
||||||
|
virtualizer.scrollToIndex(data.length - 1, { align: "end" });
|
||||||
|
} else if (event.key === "Home") {
|
||||||
|
event.preventDefault();
|
||||||
|
virtualizer.scrollToOffset(0);
|
||||||
|
} else if (event.key === "PageDown" || event.key === "PageUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
// Native page-scroll animations can keep moving after Home or End.
|
||||||
|
const direction = event.key === "PageDown" ? 1 : -1;
|
||||||
|
virtualizer.scrollBy(direction * event.currentTarget.clientHeight);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data.length, virtualizer],
|
||||||
|
);
|
||||||
|
const virtualRows = virtualizer.getVirtualItems();
|
||||||
|
const totalSize = virtualizer.getTotalSize();
|
||||||
|
const contentStyle = useMemo(() => ({ ...CONTENT_STYLE, height: totalSize }), [totalSize]);
|
||||||
|
const rowStyle = useCallback(
|
||||||
|
(offset: number): CSSProperties => ({
|
||||||
|
...CONTENT_STYLE,
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
transform: `translateY(${offset}px)`,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
style={SCROLLER_STYLE}
|
||||||
|
data-testid="import-session-list"
|
||||||
|
onFocusCapture={handleFocus}
|
||||||
|
onBlurCapture={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<View onLayout={handleHeaderLayout}>{header}</View>
|
||||||
|
<div style={contentStyle}>
|
||||||
|
{virtualRows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.key}
|
||||||
|
data-index={row.index}
|
||||||
|
data-session-key={row.key}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={rowStyle(row.start - headerHeight)}
|
||||||
|
>
|
||||||
|
{renderItem(data[row.index]!)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>{footer}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||||
import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -11,6 +11,7 @@ import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ImportSessionList } from "@/components/import-session-list";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { useProviderIcon, useProviderIcons } from "@/components/provider-icons";
|
import { useProviderIcon, useProviderIcons } from "@/components/provider-icons";
|
||||||
@@ -371,7 +372,33 @@ function ImportSessionActivityTime({ date }: { date: string }) {
|
|||||||
return <Text style={styles.rowMeta}>{label}</Text>;
|
return <Text style={styles.rowMeta}>{label}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSessionRowKey(entry: FetchRecentProviderSessionEntry): string {
|
||||||
|
return `${entry.providerId}:${entry.providerHandleId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasListHeader(
|
||||||
|
input: SheetStatusMessagesProps & {
|
||||||
|
showFilter: boolean;
|
||||||
|
hasProviderErrors: boolean;
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
input.showFilter ||
|
||||||
|
!input.isClientReady ||
|
||||||
|
input.isSnapshotUnsupported ||
|
||||||
|
input.hasNoImportableProviders ||
|
||||||
|
(input.isLoadingSessions && !input.hasRows) ||
|
||||||
|
input.importErrored ||
|
||||||
|
input.hasProviderErrors
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SessionRows({
|
function SessionRows({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
header,
|
||||||
|
listHeader,
|
||||||
|
listFooter,
|
||||||
serverId,
|
serverId,
|
||||||
entries,
|
entries,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -379,6 +406,11 @@ function SessionRows({
|
|||||||
resolveFolder,
|
resolveFolder,
|
||||||
onImportSession,
|
onImportSession,
|
||||||
}: {
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
header: SheetHeader;
|
||||||
|
listHeader: ReactNode;
|
||||||
|
listFooter: ReactNode;
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
|
entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
@@ -386,20 +418,69 @@ function SessionRows({
|
|||||||
resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null;
|
resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null;
|
||||||
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// Keep short results content-sized; only long lists need a bounded viewport.
|
||||||
|
const virtualized = ImportSessionList !== null && entries.length > 8;
|
||||||
|
const renderSessionRow = useCallback(
|
||||||
|
(entry: FetchRecentProviderSessionEntry) => (
|
||||||
|
<ImportSessionSheetRow
|
||||||
|
key={getSessionRowKey(entry)}
|
||||||
|
serverId={serverId}
|
||||||
|
entry={entry}
|
||||||
|
disabled={disabled}
|
||||||
|
importing={importingSessionKey === getSessionRowKey(entry)}
|
||||||
|
folder={resolveFolder(entry)}
|
||||||
|
onImportSession={onImportSession}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[serverId, disabled, importingSessionKey, resolveFolder, onImportSession],
|
||||||
|
);
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(item: FetchRecentProviderSessionEntry) => (
|
||||||
|
<View style={styles.listRow}>{renderSessionRow(item)}</View>
|
||||||
|
),
|
||||||
|
[renderSessionRow],
|
||||||
|
);
|
||||||
|
const virtualHeader = useMemo(
|
||||||
|
() => (listHeader ? <View style={styles.listHeader}>{listHeader}</View> : null),
|
||||||
|
[listHeader],
|
||||||
|
);
|
||||||
|
const virtualFooter = useMemo(
|
||||||
|
() => <View style={styles.listFooter}>{listFooter}</View>,
|
||||||
|
[listFooter],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.list}>
|
<AdaptiveModalSheet
|
||||||
{entries.map((entry) => (
|
visible={visible}
|
||||||
<ImportSessionSheetRow
|
onClose={onClose}
|
||||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
header={header}
|
||||||
serverId={serverId}
|
testID="import-session-sheet"
|
||||||
entry={entry}
|
desktopMaxWidth={560}
|
||||||
disabled={disabled}
|
desktopHeight={virtualized ? "85%" : undefined}
|
||||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||||
folder={resolveFolder(entry)}
|
scrollable={!virtualized}
|
||||||
onImportSession={onImportSession}
|
contentStyle={virtualized ? styles.listBody : undefined}
|
||||||
/>
|
>
|
||||||
))}
|
{ImportSessionList && virtualized ? (
|
||||||
</View>
|
<View style={styles.list}>
|
||||||
|
<ImportSessionList
|
||||||
|
data={entries}
|
||||||
|
keyExtractor={getSessionRowKey}
|
||||||
|
renderItem={renderItem}
|
||||||
|
header={virtualHeader}
|
||||||
|
footer={virtualFooter}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{listHeader}
|
||||||
|
{entries.length > 0 ? (
|
||||||
|
<View style={styles.smallList}>{entries.map(renderSessionRow)}</View>
|
||||||
|
) : null}
|
||||||
|
{listFooter}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AdaptiveModalSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,85 +807,124 @@ export function ImportSessionSheet({
|
|||||||
});
|
});
|
||||||
const showFilter = filterProviders.length > 1;
|
const showFilter = filterProviders.length > 1;
|
||||||
const showLoadMore = hasMoreSessions(queries, pageLimit);
|
const showLoadMore = hasMoreSessions(queries, pageLimit);
|
||||||
|
const showListHeader = hasListHeader({
|
||||||
|
showFilter,
|
||||||
|
isClientReady: Boolean(client),
|
||||||
|
isSnapshotUnsupported,
|
||||||
|
hasNoImportableProviders,
|
||||||
|
isLoadingSessions,
|
||||||
|
hasRows: visibleEntries.length > 0,
|
||||||
|
importErrored: importMutation.isError,
|
||||||
|
hasProviderErrors: providerErrorRows.length > 0,
|
||||||
|
});
|
||||||
|
const listHeader = useMemo(
|
||||||
|
() =>
|
||||||
|
showListHeader ? (
|
||||||
|
<View style={styles.listHeaderContent}>
|
||||||
|
{showFilter ? (
|
||||||
|
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleFilterOpen}
|
||||||
|
style={filterTriggerStyle}
|
||||||
|
testID="import-session-filter-trigger"
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
|
||||||
|
>
|
||||||
|
{selectedProvider === ALL_FILTER_VALUE ? (
|
||||||
|
<Layers size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
|
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||||
|
{selectedProviderLabel}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
<Combobox
|
||||||
|
options={filterComboboxOptions}
|
||||||
|
value={selectedProvider}
|
||||||
|
onSelect={handleFilterSelect}
|
||||||
|
renderOption={renderFilterOption}
|
||||||
|
searchable={false}
|
||||||
|
title="Filter by provider"
|
||||||
|
open={isFilterOpen}
|
||||||
|
onOpenChange={setIsFilterOpen}
|
||||||
|
anchorRef={filterAnchorRef}
|
||||||
|
desktopPlacement="bottom-start"
|
||||||
|
desktopPreventInitialFlash
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<SheetStatusMessages
|
||||||
|
isClientReady={Boolean(client)}
|
||||||
|
isSnapshotUnsupported={isSnapshotUnsupported}
|
||||||
|
hasNoImportableProviders={hasNoImportableProviders}
|
||||||
|
isLoadingSessions={isLoadingSessions}
|
||||||
|
hasRows={visibleEntries.length > 0}
|
||||||
|
importErrored={importMutation.isError}
|
||||||
|
/>
|
||||||
|
{providerErrorRows.length > 0 ? (
|
||||||
|
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null,
|
||||||
|
[
|
||||||
|
showListHeader,
|
||||||
|
showFilter,
|
||||||
|
filterTriggerStyle,
|
||||||
|
handleFilterOpen,
|
||||||
|
selectedProvider,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
|
SelectedProviderIcon,
|
||||||
|
selectedProviderLabel,
|
||||||
|
filterComboboxOptions,
|
||||||
|
handleFilterSelect,
|
||||||
|
renderFilterOption,
|
||||||
|
isFilterOpen,
|
||||||
|
client,
|
||||||
|
isSnapshotUnsupported,
|
||||||
|
hasNoImportableProviders,
|
||||||
|
isLoadingSessions,
|
||||||
|
visibleEntries.length,
|
||||||
|
importMutation.isError,
|
||||||
|
providerErrorRows,
|
||||||
|
handleRetryProvider,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const listFooter = useMemo(
|
||||||
|
() => (
|
||||||
|
<>
|
||||||
|
{showLoadMore ? (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onPress={handleLoadMore}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
testID="import-session-load-more"
|
||||||
|
>
|
||||||
|
{t("importSession.actions.loadMore")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[showLoadMore, handleLoadMore, isRefreshing, t, showEmptyState, emptyStateTitle],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<SessionRows
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
header={header}
|
header={header}
|
||||||
testID="import-session-sheet"
|
listHeader={listHeader}
|
||||||
desktopMaxWidth={560}
|
listFooter={listFooter}
|
||||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
serverId={serverId}
|
||||||
>
|
entries={visibleEntries}
|
||||||
{showFilter ? (
|
disabled={importMutation.isPending}
|
||||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
importingSessionKey={importingSessionKey}
|
||||||
<Pressable
|
resolveFolder={resolveFolder}
|
||||||
onPress={handleFilterOpen}
|
onImportSession={handleImportSession}
|
||||||
style={filterTriggerStyle}
|
/>
|
||||||
testID="import-session-filter-trigger"
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
|
|
||||||
>
|
|
||||||
{selectedProvider === ALL_FILTER_VALUE ? (
|
|
||||||
<Layers size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
)}
|
|
||||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
|
||||||
{selectedProviderLabel}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
<Combobox
|
|
||||||
options={filterComboboxOptions}
|
|
||||||
value={selectedProvider}
|
|
||||||
onSelect={handleFilterSelect}
|
|
||||||
renderOption={renderFilterOption}
|
|
||||||
searchable={false}
|
|
||||||
title="Filter by provider"
|
|
||||||
open={isFilterOpen}
|
|
||||||
onOpenChange={setIsFilterOpen}
|
|
||||||
anchorRef={filterAnchorRef}
|
|
||||||
desktopPlacement="bottom-start"
|
|
||||||
desktopPreventInitialFlash
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<SheetStatusMessages
|
|
||||||
isClientReady={Boolean(client)}
|
|
||||||
isSnapshotUnsupported={isSnapshotUnsupported}
|
|
||||||
hasNoImportableProviders={hasNoImportableProviders}
|
|
||||||
isLoadingSessions={isLoadingSessions}
|
|
||||||
hasRows={visibleEntries.length > 0}
|
|
||||||
importErrored={importMutation.isError}
|
|
||||||
/>
|
|
||||||
{providerErrorRows.length > 0 ? (
|
|
||||||
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
|
|
||||||
) : null}
|
|
||||||
{visibleEntries.length > 0 ? (
|
|
||||||
<SessionRows
|
|
||||||
serverId={serverId}
|
|
||||||
entries={visibleEntries}
|
|
||||||
disabled={importMutation.isPending}
|
|
||||||
importingSessionKey={importingSessionKey}
|
|
||||||
resolveFolder={resolveFolder}
|
|
||||||
onImportSession={handleImportSession}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{showLoadMore ? (
|
|
||||||
<View style={styles.footer}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onPress={handleLoadMore}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
testID="import-session-load-more"
|
|
||||||
>
|
|
||||||
{t("importSession.actions.loadMore")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
|
|
||||||
</AdaptiveModalSheet>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,9 +973,31 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
|
listBody: {
|
||||||
|
gap: 0,
|
||||||
|
},
|
||||||
list: {
|
list: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
marginHorizontal: -theme.spacing[2],
|
||||||
|
},
|
||||||
|
listHeader: {
|
||||||
|
marginHorizontal: theme.spacing[2],
|
||||||
|
paddingBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
|
listHeaderContent: {
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
},
|
||||||
|
smallList: {
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
listFooter: {
|
||||||
|
marginHorizontal: theme.spacing[2],
|
||||||
|
},
|
||||||
|
listRow: {
|
||||||
|
marginHorizontal: theme.spacing[2],
|
||||||
|
paddingBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
paddingTop: theme.spacing[2],
|
paddingTop: theme.spacing[2],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -249,6 +249,35 @@ describe("bare URL sentence punctuation", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["http://127.0.0.1:8059/?token=t-1", ""],
|
||||||
|
["http://example.com:8080/a", "."],
|
||||||
|
["http://localhost:8059/?token=t-1", "."],
|
||||||
|
["http://localhost:8059/?token=t-1", ")"],
|
||||||
|
["http://user@127.0.0.1:8059/?token=t-1", '"'],
|
||||||
|
["http://user:pass@127.0.0.1:8059/?token=t-1", ""],
|
||||||
|
["http://localhost", ":"],
|
||||||
|
["http://[::1]:8059/?token=t-1", "'"],
|
||||||
|
["https://github.com/o/r/compare/main...user:repo:fix/branch", ""],
|
||||||
|
["https://example.com/a:b/c", ":"],
|
||||||
|
])("links %s whole and leaves %j outside", async (url, punctuation) => {
|
||||||
|
const prepared = prepareMarkdownSource(`See ${url}${punctuation} now`);
|
||||||
|
const tree = recognizeTextLinks(
|
||||||
|
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||||||
|
prepared.linkRecognition.text,
|
||||||
|
prepared.linkRecognition.inlineCode,
|
||||||
|
);
|
||||||
|
expect(tree.children?.[0]?.children).toEqual([
|
||||||
|
{ type: "Text", content: "See " },
|
||||||
|
{
|
||||||
|
type: "Link",
|
||||||
|
attributes: expect.objectContaining({ url }),
|
||||||
|
children: [{ type: "Text", content: url }],
|
||||||
|
},
|
||||||
|
{ type: "Text", content: `${punctuation} now` },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it.each(["[Authored](https://example.com/a.)", "<https://example.com/a.>"])(
|
it.each(["[Authored](https://example.com/a.)", "<https://example.com/a.>"])(
|
||||||
"preserves punctuation in explicit destinations: %s",
|
"preserves punctuation in explicit destinations: %s",
|
||||||
async (source) => {
|
async (source) => {
|
||||||
|
|||||||
@@ -27,11 +27,17 @@ const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PAT
|
|||||||
function createFilePattern(path: string): RegExp {
|
function createFilePattern(path: string): RegExp {
|
||||||
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
||||||
return new RegExp(
|
return new RegExp(
|
||||||
String.raw`(?:https?://[^\s<>]+|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||||
"g",
|
"g",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches bare URLs that MD4C does not autolink, like single-label or bracketed hosts.
|
||||||
|
// Quotes and unbalanced closing parentheses end the URL. Trailing sentence punctuation stays outside.
|
||||||
|
const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
|
||||||
|
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
|
||||||
|
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
|
||||||
|
|
||||||
const FILE_PATTERN = createFilePattern(PROSE_PATH);
|
const FILE_PATTERN = createFilePattern(PROSE_PATH);
|
||||||
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
||||||
// The engine accepts regexes on both platforms. Classify complete code spans
|
// The engine accepts regexes on both platforms. Classify complete code spans
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
@@ -910,13 +911,9 @@ function resolveInitialActiveIndex(
|
|||||||
return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
|
return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
|
|
||||||
|
|
||||||
interface MobileBodyProps {
|
interface MobileBodyProps {
|
||||||
bottomSheetRef: BottomSheetVisibility["sheetRef"];
|
isOpen: boolean;
|
||||||
snapPoints: string[];
|
snapPoints: string[];
|
||||||
handleSheetChange: BottomSheetVisibility["handleSheetChange"];
|
|
||||||
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
|
|
||||||
handleIndicatorStyle: { backgroundColor: string };
|
handleIndicatorStyle: { backgroundColor: string };
|
||||||
titleColor: string;
|
titleColor: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -944,7 +941,22 @@ interface MobileBodyProps {
|
|||||||
safeAreaBottom: number;
|
safeAreaBottom: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
|
||||||
|
const active = useRetainedPanelActive();
|
||||||
|
if (!active) return null;
|
||||||
|
// The visibility tracker must leave the tree with its sheet.
|
||||||
|
return <MobileComboboxSheet {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
||||||
|
const {
|
||||||
|
sheetRef: bottomSheetRef,
|
||||||
|
handleSheetChange,
|
||||||
|
handleSheetDismiss,
|
||||||
|
} = useIsolatedBottomSheetVisibility({
|
||||||
|
visible: props.isOpen,
|
||||||
|
onClose: props.onClose,
|
||||||
|
});
|
||||||
const comboboxTitleStyle = useMemo(
|
const comboboxTitleStyle = useMemo(
|
||||||
() => [styles.comboboxTitle, { color: props.titleColor }],
|
() => [styles.comboboxTitle, { color: props.titleColor }],
|
||||||
[props.titleColor],
|
[props.titleColor],
|
||||||
@@ -979,13 +991,13 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<IsolatedBottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={props.bottomSheetRef}
|
ref={bottomSheetRef}
|
||||||
contextBridge={null}
|
contextBridge={null}
|
||||||
snapPoints={props.snapPoints}
|
snapPoints={props.snapPoints}
|
||||||
index={0}
|
index={0}
|
||||||
enableDynamicSizing={false}
|
enableDynamicSizing={false}
|
||||||
onChange={props.handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
onDismiss={props.handleSheetDismiss}
|
onDismiss={handleSheetDismiss}
|
||||||
backdropOpacity={0.45}
|
backdropOpacity={0.45}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
backgroundComponent={ComboboxSheetBackground}
|
backgroundComponent={ComboboxSheetBackground}
|
||||||
@@ -1168,7 +1180,8 @@ function DesktopComboboxOptionsBody(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
|
||||||
|
const active = useRetainedPanelActive();
|
||||||
const handleDesktopKey = props.handleDesktopKey;
|
const handleDesktopKey = props.handleDesktopKey;
|
||||||
const handleWebOverlayKeyDown = useCallback(
|
const handleWebOverlayKeyDown = useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
@@ -1190,6 +1203,9 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
|||||||
[props.refs, setWebOverlayScope],
|
[props.refs, setWebOverlayScope],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Remove the portal before a retained chat can freeze its closing update.
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
const overlay = (
|
const overlay = (
|
||||||
<OverlayLayerProvider layer={props.overlayLayer}>
|
<OverlayLayerProvider layer={props.overlayLayer}>
|
||||||
<View
|
<View
|
||||||
@@ -1309,6 +1325,7 @@ export function Combobox({
|
|||||||
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
|
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
|
||||||
const resolvedTitle = title ?? t("common.actions.select");
|
const resolvedTitle = title ?? t("common.actions.select");
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
const active = useRetainedPanelActive();
|
||||||
const floatingLayer = useOverlayLayer("floating");
|
const floatingLayer = useOverlayLayer("floating");
|
||||||
const safeAreaInsets = useSafeAreaInsets();
|
const safeAreaInsets = useSafeAreaInsets();
|
||||||
const titleColor = theme.colors.foreground;
|
const titleColor = theme.colors.foreground;
|
||||||
@@ -1348,6 +1365,10 @@ export function Combobox({
|
|||||||
bumpSearchResetKey();
|
bumpSearchResetKey();
|
||||||
}, [setOpen, setSearchQueryWithCallback]);
|
}, [setOpen, setSearchQueryWithCallback]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active && isOpen) handleClose();
|
||||||
|
}, [active, isOpen, handleClose]);
|
||||||
|
|
||||||
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
|
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
|
||||||
|
|
||||||
const collisionPadding = useMemo(computeCollisionPadding, []);
|
const collisionPadding = useMemo(computeCollisionPadding, []);
|
||||||
@@ -1413,16 +1434,6 @@ export function Combobox({
|
|||||||
referenceWidth,
|
referenceWidth,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
|
||||||
sheetRef: bottomSheetRef,
|
|
||||||
handleSheetChange,
|
|
||||||
handleSheetDismiss,
|
|
||||||
} = useIsolatedBottomSheetVisibility({
|
|
||||||
visible: isOpen,
|
|
||||||
isEnabled: isMobile,
|
|
||||||
onClose: handleClose,
|
|
||||||
});
|
|
||||||
|
|
||||||
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
||||||
const sanitizedSearchValue = searchQuery.trim();
|
const sanitizedSearchValue = searchQuery.trim();
|
||||||
const showCustomOption = useMemo(
|
const showCustomOption = useMemo(
|
||||||
@@ -1572,10 +1583,8 @@ export function Combobox({
|
|||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<MobileComboboxBody
|
<MobileComboboxBody
|
||||||
bottomSheetRef={bottomSheetRef}
|
isOpen={isOpen}
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
handleSheetChange={handleSheetChange}
|
|
||||||
handleSheetDismiss={handleSheetDismiss}
|
|
||||||
handleIndicatorStyle={handleIndicatorStyle}
|
handleIndicatorStyle={handleIndicatorStyle}
|
||||||
titleColor={titleColor}
|
titleColor={titleColor}
|
||||||
title={resolvedTitle}
|
title={resolvedTitle}
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ const CATALOG_DATA = [
|
|||||||
title: "Auggie CLI",
|
title: "Auggie CLI",
|
||||||
description:
|
description:
|
||||||
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||||
version: "0.33.0",
|
version: "0.36.0",
|
||||||
iconId: "auggie",
|
iconId: "auggie",
|
||||||
installLink: "https://www.augmentcode.com/",
|
installLink: "https://www.augmentcode.com/",
|
||||||
command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"],
|
command: ["npx", "-y", "@augmentcode/auggie@0.36.0", "--acp"],
|
||||||
env: {
|
env: {
|
||||||
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
||||||
},
|
},
|
||||||
@@ -60,10 +60,10 @@ const CATALOG_DATA = [
|
|||||||
title: "Cline",
|
title: "Cline",
|
||||||
description:
|
description:
|
||||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||||
version: "3.0.46",
|
version: "3.0.73",
|
||||||
iconId: "cline",
|
iconId: "cline",
|
||||||
installLink: "https://cline.bot/cli",
|
installLink: "https://cline.bot/cli",
|
||||||
command: ["npx", "-y", "cline@3.0.46", "--acp"],
|
command: ["npx", "-y", "cline@3.0.73", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "codebuddy-code",
|
id: "codebuddy-code",
|
||||||
@@ -123,10 +123,10 @@ const CATALOG_DATA = [
|
|||||||
id: "deepagents",
|
id: "deepagents",
|
||||||
title: "DeepAgents",
|
title: "DeepAgents",
|
||||||
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
|
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
|
||||||
version: "0.1.20",
|
version: "0.1.36",
|
||||||
iconId: "deepagents",
|
iconId: "deepagents",
|
||||||
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
||||||
command: ["npx", "-y", "deepagents-acp@0.1.20"],
|
command: ["npx", "-y", "deepagents-acp@0.1.36"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "devin",
|
id: "devin",
|
||||||
@@ -141,29 +141,29 @@ const CATALOG_DATA = [
|
|||||||
id: "dimcode",
|
id: "dimcode",
|
||||||
title: "DimCode",
|
title: "DimCode",
|
||||||
description: "A coding agent that puts leading models at your command.",
|
description: "A coding agent that puts leading models at your command.",
|
||||||
version: "0.2.36",
|
version: "0.5.21",
|
||||||
iconId: "dimcode",
|
iconId: "dimcode",
|
||||||
installLink: "https://dimcode.dev/docs/acp.html",
|
installLink: "https://dimcode.dev/docs/acp.html",
|
||||||
command: ["npx", "-y", "dimcode@0.2.36", "acp"],
|
command: ["npx", "-y", "dimcode@0.5.21", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "dirac",
|
id: "dirac",
|
||||||
title: "Dirac",
|
title: "Dirac",
|
||||||
description:
|
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.",
|
"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.4.22",
|
version: "0.5.23",
|
||||||
iconId: "dirac",
|
iconId: "dirac",
|
||||||
installLink: "https://dirac.run",
|
installLink: "https://dirac.run",
|
||||||
command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"],
|
command: ["npx", "-y", "dirac-cli@0.5.23", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "factory-droid",
|
id: "factory-droid",
|
||||||
title: "Factory Droid",
|
title: "Factory Droid",
|
||||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||||
version: "0.179.0",
|
version: "0.249.0",
|
||||||
iconId: "factory-droid",
|
iconId: "factory-droid",
|
||||||
installLink: "https://factory.ai/product/cli",
|
installLink: "https://factory.ai/product/cli",
|
||||||
command: ["npx", "-y", "droid@0.179.0", "exec", "--output-format", "acp-daemon"],
|
command: ["npx", "-y", "droid@0.249.0", "exec", "--output-format", "acp-daemon"],
|
||||||
env: {
|
env: {
|
||||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||||
@@ -174,19 +174,19 @@ const CATALOG_DATA = [
|
|||||||
id: "fast-agent",
|
id: "fast-agent",
|
||||||
title: "fast-agent",
|
title: "fast-agent",
|
||||||
description: "Code and build agents with comprehensive multi-provider support",
|
description: "Code and build agents with comprehensive multi-provider support",
|
||||||
version: "0.9.22",
|
version: "0.10.47",
|
||||||
iconId: "fast-agent",
|
iconId: "fast-agent",
|
||||||
installLink: "https://fast-agent.ai/acp/",
|
installLink: "https://fast-agent.ai/acp/",
|
||||||
command: ["uvx", "--from", "fast-agent-acp==0.9.22", "fast-agent-acp", "-x"],
|
command: ["uvx", "--from", "fast-agent-acp==0.10.47", "fast-agent-acp", "-x"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gemini",
|
id: "gemini",
|
||||||
title: "Gemini CLI",
|
title: "Gemini CLI",
|
||||||
description: "Google's official CLI for Gemini",
|
description: "Google's official CLI for Gemini",
|
||||||
version: "0.52.0",
|
version: "0.65.0",
|
||||||
iconId: "gemini",
|
iconId: "gemini",
|
||||||
installLink: "https://geminicli.com",
|
installLink: "https://geminicli.com",
|
||||||
command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"],
|
command: ["npx", "-y", "@google/gemini-cli@0.65.0", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gjc",
|
id: "gjc",
|
||||||
@@ -205,11 +205,11 @@ const CATALOG_DATA = [
|
|||||||
id: "glm-acp-agent",
|
id: "glm-acp-agent",
|
||||||
title: "GLM Agent",
|
title: "GLM Agent",
|
||||||
description:
|
description:
|
||||||
"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.",
|
"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.3.0",
|
version: "1.15.0",
|
||||||
iconId: "glm-acp-agent",
|
iconId: "glm-acp-agent",
|
||||||
installLink: "https://github.com/stefandevo/glm-acp-agent",
|
installLink: "https://github.com/stefandevo/glm-acp-agent",
|
||||||
command: ["npx", "-y", "glm-acp-agent@1.3.0"],
|
command: ["npx", "-y", "glm-acp-agent@1.15.0"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "goose",
|
id: "goose",
|
||||||
@@ -289,20 +289,20 @@ const CATALOG_DATA = [
|
|||||||
id: "minimax-code",
|
id: "minimax-code",
|
||||||
title: "MiniMax Code",
|
title: "MiniMax Code",
|
||||||
description: "MiniMax's coding agent for the terminal",
|
description: "MiniMax's coding agent for the terminal",
|
||||||
version: "0.1.2",
|
version: "0.6.13",
|
||||||
iconId: "minimax-code",
|
iconId: "minimax-code",
|
||||||
installLink: "https://agent.minimax.io",
|
installLink: "https://agent.minimax.io",
|
||||||
command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"],
|
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "minion-code",
|
id: "minion-code",
|
||||||
title: "Minion Code",
|
title: "Minion Code",
|
||||||
description:
|
description:
|
||||||
"An enhanced AI code assistant built on the Minion framework with rich development tools",
|
"An enhanced AI code assistant built on the Minion framework with rich development tools",
|
||||||
version: "0.1.44",
|
version: "0.1.45",
|
||||||
iconId: "minion-code",
|
iconId: "minion-code",
|
||||||
installLink: "https://github.com/femto/minion-code",
|
installLink: "https://github.com/femto/minion-code",
|
||||||
command: ["uvx", "--from", "minion-code==0.1.44", "minion-code", "acp"],
|
command: ["uvx", "--from", "minion-code==0.1.45", "minion-code", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mistral-vibe",
|
id: "mistral-vibe",
|
||||||
@@ -317,10 +317,10 @@ const CATALOG_DATA = [
|
|||||||
id: "nova",
|
id: "nova",
|
||||||
title: "Nova",
|
title: "Nova",
|
||||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||||
version: "1.1.29",
|
version: "1.1.59",
|
||||||
iconId: "nova",
|
iconId: "nova",
|
||||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||||
command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"],
|
command: ["npx", "-y", "@compass-ai/nova@1.1.59", "acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "poolside",
|
id: "poolside",
|
||||||
@@ -335,19 +335,19 @@ const CATALOG_DATA = [
|
|||||||
id: "qoder",
|
id: "qoder",
|
||||||
title: "Qoder CLI",
|
title: "Qoder CLI",
|
||||||
description: "AI coding assistant with agentic capabilities",
|
description: "AI coding assistant with agentic capabilities",
|
||||||
version: "1.1.4",
|
version: "1.1.78",
|
||||||
iconId: "qoder",
|
iconId: "qoder",
|
||||||
installLink: "https://qoder.com",
|
installLink: "https://qoder.com",
|
||||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.4", "--acp"],
|
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.78", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "qwen-code",
|
id: "qwen-code",
|
||||||
title: "Qwen Code",
|
title: "Qwen Code",
|
||||||
description: "Alibaba's Qwen coding assistant",
|
description: "Alibaba's Qwen coding assistant",
|
||||||
version: "0.20.1",
|
version: "0.26.0",
|
||||||
iconId: "qwen-code",
|
iconId: "qwen-code",
|
||||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"],
|
command: ["npx", "-y", "@qwen-code/qwen-code@0.26.0", "--acp", "--experimental-skills"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sigit",
|
id: "sigit",
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ describe("ACP provider catalog", () => {
|
|||||||
it("offers MiniMax Code through its pinned public ACP package", () => {
|
it("offers MiniMax Code through its pinned public ACP package", () => {
|
||||||
expect(findProvider("minimax-code")).toMatchObject({
|
expect(findProvider("minimax-code")).toMatchObject({
|
||||||
title: "MiniMax Code",
|
title: "MiniMax Code",
|
||||||
version: "0.1.2",
|
version: "0.6.13",
|
||||||
command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"],
|
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
|
||||||
});
|
});
|
||||||
expect(findProvider("minimax-code").iconSvg).toContain("<svg");
|
expect(findProvider("minimax-code").iconSvg).toContain("<svg");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { useRevealedText } from "@/hooks/use-revealed-text";
|
||||||
import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate";
|
import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate";
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
@@ -705,6 +706,21 @@ describe("evaluatePluginClientBundle", () => {
|
|||||||
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
|
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", () => {
|
it("keeps shared and client runtime exports separate", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
evaluatePluginClientBundle(
|
evaluatePluginClientBundle(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { requirePluginText } from "@getpaseo/plugin/client/host";
|
|||||||
import type { createPluginHosts } from "./hosts";
|
import type { createPluginHosts } from "./hosts";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import * as pluginUiRuntime from "./react-native/ui";
|
import * as pluginUiRuntime from "./react-native/ui";
|
||||||
|
import { useRevealedText } from "@/hooks/use-revealed-text";
|
||||||
import { useSettings } from "./settings/use-settings";
|
import { useSettings } from "./settings/use-settings";
|
||||||
import * as pluginSharedRuntime from "@getpaseo/plugin";
|
import * as pluginSharedRuntime from "@getpaseo/plugin";
|
||||||
import * as pluginClientRuntime from "@getpaseo/plugin/client";
|
import * as pluginClientRuntime from "@getpaseo/plugin/client";
|
||||||
@@ -114,6 +115,10 @@ export type PluginClientRuntime = Pick<
|
|||||||
| "addHeaderButton"
|
| "addHeaderButton"
|
||||||
> & { hosts: ReturnType<typeof createPluginHosts> };
|
> & { 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(
|
export function runPluginClientBundle(
|
||||||
id: string,
|
id: string,
|
||||||
bundle: string,
|
bundle: string,
|
||||||
@@ -503,7 +508,7 @@ export function runPluginClientBundle(
|
|||||||
};
|
};
|
||||||
const runtimeRequire = (name: string): unknown => {
|
const runtimeRequire = (name: string): unknown => {
|
||||||
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
|
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
|
||||||
if (name === "@getpaseo/plugin/client/react-native") return pluginUiRuntime;
|
if (name === "@getpaseo/plugin/client/react-native") return pluginReactNativeCompatRuntime;
|
||||||
if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime;
|
if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime;
|
||||||
if (name === "react") return React;
|
if (name === "react") return React;
|
||||||
if (name === "react/jsx-runtime") return ReactJsxRuntime;
|
if (name === "react/jsx-runtime") return ReactJsxRuntime;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.13.0-beta.2",
|
"@getpaseo/client": "0.13.0",
|
||||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
"@getpaseo/protocol": "0.13.0",
|
||||||
"@getpaseo/server": "0.13.0-beta.2",
|
"@getpaseo/server": "0.13.0",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"description": "Paseo client SDK package",
|
"description": "Paseo client SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
"@getpaseo/protocol": "0.13.0",
|
||||||
"@getpaseo/relay": "0.13.0-beta.2",
|
"@getpaseo/relay": "0.13.0",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { execFile, spawnSync } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
const hasSsh = !spawnSync("ssh", ["-V"]).error;
|
||||||
|
|
||||||
|
test.skipIf(!hasSsh)(
|
||||||
|
"an SSH peer abort during a pending write closes the tunnel without crashing its caller",
|
||||||
|
async () => {
|
||||||
|
// Isolate the caller so an unhandled stream error fails this assertion instead
|
||||||
|
// of crashing the test worker. Both the TCP peer and OpenSSH are real.
|
||||||
|
const { stdout } = await run(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"--import",
|
||||||
|
"tsx",
|
||||||
|
"--input-type=module",
|
||||||
|
"-e",
|
||||||
|
`
|
||||||
|
import { createServer, createConnection } from 'node:net';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import { createSshTunnel } from ${JSON.stringify(new URL("./ssh-tunnel.ts", import.meta.url).href)};
|
||||||
|
|
||||||
|
const remote = createServer(socket => socket.destroy());
|
||||||
|
remote.listen(0, '127.0.0.1');
|
||||||
|
await once(remote, 'listening');
|
||||||
|
const tunnel = await createSshTunnel({
|
||||||
|
host: '127.0.0.1', sshPort: remote.address().port, daemonPort: 6767,
|
||||||
|
});
|
||||||
|
const [host, port] = tunnel.endpoint.split(':');
|
||||||
|
const client = createConnection({ host, port: Number(port) });
|
||||||
|
client.on('error', () => undefined);
|
||||||
|
try {
|
||||||
|
const closed = new Promise(resolve => client.once('close', resolve));
|
||||||
|
await once(client, 'connect');
|
||||||
|
client.write(Buffer.alloc(8 * 1024 * 1024, 65));
|
||||||
|
await closed;
|
||||||
|
console.log(JSON.stringify({ closed: client.destroyed, failure: tunnel.failureDetail() }));
|
||||||
|
} finally {
|
||||||
|
client.destroy();
|
||||||
|
tunnel.close();
|
||||||
|
await new Promise(resolve => remote.close(resolve));
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
|
expect(JSON.parse(stdout)).toEqual({
|
||||||
|
closed: true,
|
||||||
|
failure: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(JSON.parse(stdout).failure).not.toBe("");
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -54,10 +54,12 @@ export function createSshTunnel(target: SshTransportTarget): Promise<SshTunnel>
|
|||||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||||
stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT);
|
stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT);
|
||||||
});
|
});
|
||||||
child.on("error", (error) => {
|
const fail = (error: Error) => {
|
||||||
failure = error.message;
|
failure = error.message;
|
||||||
acceptedSocket.destroy(error);
|
acceptedSocket.destroy(error);
|
||||||
});
|
};
|
||||||
|
child.on("error", fail);
|
||||||
|
child.stdin.on("error", fail);
|
||||||
child.on("exit", (code, signal) => {
|
child.on("exit", (code, signal) => {
|
||||||
if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal);
|
if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal);
|
||||||
acceptedSocket.destroy(failure ? new Error(failure) : undefined);
|
acceptedSocket.destroy(failure ? new Error(failure) : undefined);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"homepage": "https://paseo.sh",
|
"homepage": "https://paseo.sh",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ExpoTwoWayAudio",
|
"ExpoTwoWayAudio",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"!dist/**/*.map",
|
"!dist/**/*.map",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"description": "Paseo plugin SDK package",
|
"description": "Paseo plugin SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -72,8 +72,8 @@
|
|||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.13.0-beta.2",
|
"@getpaseo/client": "0.13.0",
|
||||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
"@getpaseo/protocol": "0.13.0",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -83,8 +83,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.13.0-beta.2",
|
"@getpaseo/client": "0.13.0",
|
||||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
"@getpaseo/protocol": "0.13.0",
|
||||||
"react": ">=19.1.0 <20",
|
"react": ">=19.1.0 <20",
|
||||||
"react-native": ">=0.81.5",
|
"react-native": ">=0.81.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
@@ -101,12 +101,29 @@ describe("plugin SDK import boundaries", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(compatibilityEntries)("%s only re-exports the UI entry", (entry) => {
|
it.each(compatibilityEntries)("%s respects the client boundary", (entry) => {
|
||||||
const source = readFileSync(
|
expect(
|
||||||
path.resolve(sourceDirectory, "..", manifest.exports[entry].source),
|
boundaryViolations(
|
||||||
"utf8",
|
path.resolve(sourceDirectory, "..", manifest.exports[entry].source),
|
||||||
);
|
"client",
|
||||||
expect(source.replace(/\/\/[^\n]*|\/\*[^]*?\*\//g, "").trim()).toBe('export * from "./ui.js";');
|
),
|
||||||
|
).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(Object.entries(entries))(
|
it.each(Object.entries(entries))(
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
|
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
|
||||||
/** @deprecated Import Paseo UI from @getpaseo/plugin/client/ui. */
|
/** @deprecated Import Paseo UI from @getpaseo/plugin/client/ui. */
|
||||||
export * from "./ui.js";
|
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;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"description": "Paseo shared protocol schemas and wire types",
|
"description": "Paseo shared protocol schemas and wire types",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/server",
|
"dist/server",
|
||||||
@@ -107,11 +107,11 @@
|
|||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.13.0-beta.2",
|
"@getpaseo/client": "0.13.0",
|
||||||
"@getpaseo/highlight": "0.13.0-beta.2",
|
"@getpaseo/highlight": "0.13.0",
|
||||||
"@getpaseo/plugin": "0.13.0-beta.2",
|
"@getpaseo/plugin": "0.13.0",
|
||||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
"@getpaseo/protocol": "0.13.0",
|
||||||
"@getpaseo/relay": "0.13.0-beta.2",
|
"@getpaseo/relay": "0.13.0",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
@@ -7,6 +7,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|||||||
import { ClaudeAgentClient } from "./agent.js";
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
import { claudeProjectDirSync } from "./project-dir.js";
|
import { claudeProjectDirSync } from "./project-dir.js";
|
||||||
import { withClaudeReplayHistory } from "./transcript-history.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.
|
* A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline.
|
||||||
@@ -29,19 +32,18 @@ describe("ClaudeAgentSession persisted history load", () => {
|
|||||||
let configDir: string;
|
let configDir: string;
|
||||||
let records: LogRecord[];
|
let records: LogRecord[];
|
||||||
|
|
||||||
async function resume(): Promise<void> {
|
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
|
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
|
||||||
queryFactory: vi.fn(() => {
|
queryFactory:
|
||||||
throw new Error("history load must not start a query");
|
queryFactory ??
|
||||||
}),
|
(() => {
|
||||||
|
throw new Error("history load must not start a query");
|
||||||
|
}),
|
||||||
|
resolveBinary: async () => process.execPath,
|
||||||
resolveVersion: async () => "2.1.220",
|
resolveVersion: async () => "2.1.220",
|
||||||
});
|
});
|
||||||
const session = await client.resumeSession(
|
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
|
||||||
{ provider: "claude", sessionId: SESSION_ID },
|
|
||||||
{ cwd },
|
|
||||||
);
|
|
||||||
await session.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function transcriptPath(): string {
|
function transcriptPath(): string {
|
||||||
@@ -58,12 +60,218 @@ describe("ClaudeAgentSession persisted history load", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
rmSync(tempRoot, { recursive: true, force: true });
|
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 () => {
|
test("logs the resolved transcript path when the transcript does not exist", async () => {
|
||||||
await resume();
|
await (await resume()).close();
|
||||||
|
|
||||||
expect(records).toContainEqual(
|
expect(records).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -79,7 +287,7 @@ describe("ClaudeAgentSession persisted history load", () => {
|
|||||||
// A directory at the transcript path exists but cannot be read as a file.
|
// A directory at the transcript path exists but cannot be read as a file.
|
||||||
mkdirSync(transcriptPath(), { recursive: true });
|
mkdirSync(transcriptPath(), { recursive: true });
|
||||||
|
|
||||||
await resume();
|
await (await resume()).close();
|
||||||
|
|
||||||
expect(records).toContainEqual(
|
expect(records).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -148,7 +356,7 @@ describe("ClaudeAgentSession persisted history load", () => {
|
|||||||
chmodSync(path.join(configDir, "projects"), 0o000);
|
chmodSync(path.join(configDir, "projects"), 0o000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resume();
|
await (await resume()).close();
|
||||||
} finally {
|
} finally {
|
||||||
chmodSync(path.join(configDir, "projects"), 0o755);
|
chmodSync(path.join(configDir, "projects"), 0o755);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2326,6 +2326,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
{ type: "provider_subagent" }
|
{ type: "provider_subagent" }
|
||||||
>[] = [];
|
>[] = [];
|
||||||
private historyPending = false;
|
private historyPending = false;
|
||||||
|
private replayingHistory = false;
|
||||||
private turnState: TurnState = "idle";
|
private turnState: TurnState = "idle";
|
||||||
private nextTurnOrdinal = 1;
|
private nextTurnOrdinal = 1;
|
||||||
private cancelCurrentTurn: (() => void) | null = null;
|
private cancelCurrentTurn: (() => void) | null = null;
|
||||||
@@ -5253,6 +5254,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
private loadPersistedHistory(sessionId: string): void {
|
private loadPersistedHistory(sessionId: string): void {
|
||||||
let historyPath: string | null = null;
|
let historyPath: string | null = null;
|
||||||
|
this.replayingHistory = true;
|
||||||
try {
|
try {
|
||||||
this.taskState.reset();
|
this.taskState.reset();
|
||||||
historyPath = this.resolveHistoryPath(sessionId);
|
historyPath = this.resolveHistoryPath(sessionId);
|
||||||
@@ -5272,6 +5274,8 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
{ err: error, sessionId, historyPath },
|
{ err: error, sessionId, historyPath },
|
||||||
"Failed to load Claude history from transcript",
|
"Failed to load Claude history from transcript",
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
this.replayingHistory = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6039,6 +6043,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private detectFileKind(filePath: string): string {
|
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 {
|
try {
|
||||||
return fs.existsSync(filePath) ? "update" : "add";
|
return fs.existsSync(filePath) ? "update" : "add";
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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,6 +7,7 @@ import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
|
|||||||
import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js";
|
import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js";
|
||||||
|
|
||||||
import { messages } from "./history.js";
|
import { messages } from "./history.js";
|
||||||
|
import { buildOpenCodeSubagentSubtitle } from "../subagent-presentation.js";
|
||||||
|
|
||||||
import type { V2Api } from "./api.js";
|
import type { V2Api } from "./api.js";
|
||||||
interface ChildrenOptions {
|
interface ChildrenOptions {
|
||||||
@@ -65,11 +66,23 @@ export class SessionChildren {
|
|||||||
if (active[info.id]) status = "running";
|
if (active[info.id]) status = "running";
|
||||||
else if (info.outcome === "failed") status = "failed";
|
else if (info.outcome === "failed") status = "failed";
|
||||||
else if (info.outcome === "interrupted") status = "canceled";
|
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 = {
|
const presentation: ProviderSubagentInputEvent = {
|
||||||
type: "upsert",
|
type: "upsert",
|
||||||
id: info.id,
|
id: info.id,
|
||||||
parentSubagentId: info.parentID === this.options.id ? null : info.parentID,
|
parentSubagentId: info.parentID === this.options.id ? null : info.parentID,
|
||||||
title: info.title ?? null,
|
title: info.title ?? null,
|
||||||
|
...(subtitle ? { subtitle } : {}),
|
||||||
status,
|
status,
|
||||||
cwd: info.location.directory,
|
cwd: info.location.directory,
|
||||||
timestamp: new Date(info.time.updated).toISOString(),
|
timestamp: new Date(info.time.updated).toISOString(),
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ interface FakeSpawnBehavior {
|
|||||||
|
|
||||||
interface FakeSpawnController {
|
interface FakeSpawnController {
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
nextPid: number;
|
|
||||||
peakActiveCount: number;
|
peakActiveCount: number;
|
||||||
processes: FakeChildProcess[];
|
processes: FakeChildProcess[];
|
||||||
queue: FakeSpawnBehavior[];
|
queue: FakeSpawnBehavior[];
|
||||||
@@ -25,7 +24,6 @@ interface FakeSpawnController {
|
|||||||
|
|
||||||
const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
|
const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
|
||||||
activeCount: 0,
|
activeCount: 0,
|
||||||
nextPid: 1000,
|
|
||||||
peakActiveCount: 0,
|
peakActiveCount: 0,
|
||||||
processes: [],
|
processes: [],
|
||||||
queue: [],
|
queue: [],
|
||||||
@@ -35,7 +33,6 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.activeCount = 0;
|
this.activeCount = 0;
|
||||||
this.nextPid = 1000;
|
|
||||||
this.peakActiveCount = 0;
|
this.peakActiveCount = 0;
|
||||||
this.processes = [];
|
this.processes = [];
|
||||||
this.queue = [];
|
this.queue = [];
|
||||||
@@ -43,7 +40,8 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
class FakeChildProcess extends EventEmitter {
|
class FakeChildProcess extends EventEmitter {
|
||||||
public readonly pid: number;
|
// This is not an OS process; tree cleanup must use the direct-child fallback.
|
||||||
|
public readonly pid = undefined;
|
||||||
public readonly stderr = new EventEmitter();
|
public readonly stderr = new EventEmitter();
|
||||||
public readonly stdout = new EventEmitter();
|
public readonly stdout = new EventEmitter();
|
||||||
public killed = false;
|
public killed = false;
|
||||||
@@ -57,8 +55,6 @@ class FakeChildProcess extends EventEmitter {
|
|||||||
public constructor(behavior: FakeSpawnBehavior) {
|
public constructor(behavior: FakeSpawnBehavior) {
|
||||||
super();
|
super();
|
||||||
this.behavior = behavior;
|
this.behavior = behavior;
|
||||||
this.pid = fakeSpawnController.nextPid;
|
|
||||||
fakeSpawnController.nextPid += 1;
|
|
||||||
|
|
||||||
fakeSpawnController.activeCount += 1;
|
fakeSpawnController.activeCount += 1;
|
||||||
fakeSpawnController.peakActiveCount = Math.max(
|
fakeSpawnController.peakActiveCount = Math.max(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
submitGitCommandTrace,
|
submitGitCommandTrace,
|
||||||
} from "./git-command-trace.js";
|
} from "./git-command-trace.js";
|
||||||
import { spawnProcess } from "./spawn.js";
|
import { spawnProcess } from "./spawn.js";
|
||||||
|
import { signalProcessTree } from "./tree-kill.js";
|
||||||
import {
|
import {
|
||||||
GitProcessScheduler,
|
GitProcessScheduler,
|
||||||
type GitProcessPriority,
|
type GitProcessPriority,
|
||||||
@@ -422,7 +423,7 @@ function executeGitCommand<Output>(
|
|||||||
|
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
timeoutError = new Error(`Git command timed out after ${timeout}ms: ${command}`);
|
timeoutError = new Error(`Git command timed out after ${timeout}ms: ${command}`);
|
||||||
child.kill("SIGKILL");
|
void signalProcessTree(child, "SIGKILL");
|
||||||
settle(() => reject(timeoutError));
|
settle(() => reject(timeoutError));
|
||||||
if (processExit) {
|
if (processExit) {
|
||||||
settleTimeoutTrace(processExit.exitCode, processExit.signal);
|
settleTimeoutTrace(processExit.exitCode, processExit.signal);
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { createServer, type Socket } from "node:net";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { runGitCommand } from "./run-git-command.js";
|
import { runGitCommand } from "./run-git-command.js";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
function allowConnectionReset(error: NodeJS.ErrnoException): void {
|
||||||
|
if (error.code !== "ECONNRESET") throw error;
|
||||||
|
}
|
||||||
|
|
||||||
function makeTempRepo(): string {
|
function makeTempRepo(): string {
|
||||||
const repo = mkdtempSync(path.join(tmpdir(), "paseo-git-shell-"));
|
const repo = mkdtempSync(path.join(tmpdir(), "paseo-git-shell-"));
|
||||||
tempDirs.push(repo);
|
tempDirs.push(repo);
|
||||||
@@ -20,6 +25,49 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("runGitCommand shell behavior", () => {
|
describe("runGitCommand shell behavior", () => {
|
||||||
|
it.each(["http", "ssh"])(
|
||||||
|
"closes a stalled %s fetch connection on timeout and admits the next command",
|
||||||
|
async (protocol) => {
|
||||||
|
const repo = makeTempRepo();
|
||||||
|
await runGitCommand(["init"], { cwd: repo });
|
||||||
|
const sockets: Socket[] = [];
|
||||||
|
const server = createServer((socket) => {
|
||||||
|
sockets.push(socket);
|
||||||
|
// Force-killing a Windows peer resets the connection instead of sending EOF.
|
||||||
|
socket.on("error", allowConnectionReset);
|
||||||
|
// Consume the request but never answer, keeping the real transport helper busy.
|
||||||
|
socket.resume();
|
||||||
|
});
|
||||||
|
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 TCP address");
|
||||||
|
const remote = `${protocol}://127.0.0.1:${address.port}/repo.git`;
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
runGitCommand(["fetch", remote, "main"], {
|
||||||
|
cwd: repo,
|
||||||
|
timeout: 2000,
|
||||||
|
envOverlay: {
|
||||||
|
NO_PROXY: "127.0.0.1",
|
||||||
|
no_proxy: "127.0.0.1",
|
||||||
|
GIT_SSH_COMMAND: "ssh -F none -o BatchMode=yes",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Git command timed out after 2000ms");
|
||||||
|
|
||||||
|
expect(sockets).toHaveLength(1);
|
||||||
|
await expect.poll(() => sockets[0]?.destroyed, { timeout: 3000 }).toBe(true);
|
||||||
|
await expect(runGitCommand(["--version"], { cwd: repo })).resolves.toMatchObject({
|
||||||
|
exitCode: 0,
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
for (const socket of sockets) socket.destroy();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("passes git arguments directly instead of through the platform shell", async () => {
|
it("passes git arguments directly instead of through the platform shell", async () => {
|
||||||
const repo = makeTempRepo();
|
const repo = makeTempRepo();
|
||||||
const literalName = "%PASEO_GIT_SHELL_SENTINEL%";
|
const literalName = "%PASEO_GIT_SHELL_SENTINEL%";
|
||||||
|
|||||||
@@ -55,10 +55,18 @@ function createLegacyWorktreeForTest(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function allowConnectionReset(error: NodeJS.ErrnoException): void {
|
||||||
|
if (error.code !== "ECONNRESET") throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// A remote that accepts connections and never answers, like a VPN-only host while off the VPN.
|
// A remote that accepts connections and never answers, like a VPN-only host while off the VPN.
|
||||||
async function startSilentRemote(): Promise<{ url: string; close: () => Promise<void> }> {
|
async function startSilentRemote(): Promise<{ url: string; close: () => Promise<void> }> {
|
||||||
const connections = new Set<Socket>();
|
const connections = new Set<Socket>();
|
||||||
const server = createServer((socket) => connections.add(socket));
|
const server = createServer((socket) => {
|
||||||
|
connections.add(socket);
|
||||||
|
// Timeout cleanup force-kills the peer, which can reset its connection on Windows.
|
||||||
|
socket.on("error", allowConnectionReset);
|
||||||
|
});
|
||||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
const { port } = server.address() as AddressInfo;
|
const { port } = server.address() as AddressInfo;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,92 @@
|
|||||||
|
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { fetchUsage } from "./usage.js";
|
||||||
|
|
||||||
|
it("shows the reported Pro percentage pools without treating bonus spend as an overage", async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "cursor-reported-pools-"));
|
||||||
|
const locator = join(directory, "auth.json");
|
||||||
|
try {
|
||||||
|
await writeFile(locator, JSON.stringify({ accessToken: "fixture-token" }));
|
||||||
|
const report = await fetchUsage({ store: "file", locator }, async () =>
|
||||||
|
Response.json({
|
||||||
|
billingCycleStart: "2026-09-25T07:10:08.000Z",
|
||||||
|
billingCycleEnd: "2026-10-25T07:10:08.000Z",
|
||||||
|
planUsage: {
|
||||||
|
totalSpend: 5717,
|
||||||
|
includedSpend: 2000,
|
||||||
|
bonusSpend: 3717,
|
||||||
|
limit: 2000,
|
||||||
|
autoPercentUsed: 9.157777777777778,
|
||||||
|
apiPercentUsed: 35.46666666666667,
|
||||||
|
totalPercentUsed: 11.54949494949495,
|
||||||
|
},
|
||||||
|
spendLimitUsage: { individualLimit: 100, individualRemaining: 100, limitType: "user" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(report).toMatchObject({
|
||||||
|
status: "available",
|
||||||
|
windows: [
|
||||||
|
{ id: "plan_usage", usedPct: 11.54949494949495 },
|
||||||
|
{ id: "cursor_usage", usedPct: 9.157777777777778 },
|
||||||
|
{ id: "third_party_usage", usedPct: 35.46666666666667 },
|
||||||
|
],
|
||||||
|
balances: [],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Pro+", 42015, 7000, 34.583333333333336, 4.6818181818181825, 32.07251908396947],
|
||||||
|
["Teams", 77145, 2000, 61.8352380952381, 61.09, 61.716],
|
||||||
|
] as const)(
|
||||||
|
"preserves the reported %s pools separately from bonus-inclusive dollars",
|
||||||
|
async (_plan, totalSpend, limit, autoPercentUsed, apiPercentUsed, totalPercentUsed) => {
|
||||||
|
const report = await reportFor({
|
||||||
|
totalSpend,
|
||||||
|
limit,
|
||||||
|
autoPercentUsed,
|
||||||
|
apiPercentUsed,
|
||||||
|
totalPercentUsed,
|
||||||
|
});
|
||||||
|
expect(report).toMatchObject({
|
||||||
|
status: "available",
|
||||||
|
windows: [
|
||||||
|
{ id: "plan_usage", usedPct: totalPercentUsed, summary: true },
|
||||||
|
{ id: "cursor_usage", usedPct: autoPercentUsed },
|
||||||
|
{ id: "third_party_usage", usedPct: apiPercentUsed },
|
||||||
|
],
|
||||||
|
balances: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("retains a zero percentage without inventing absent breakdowns", async () => {
|
||||||
|
expect(await reportFor({ totalPercentUsed: "0", autoPercentUsed: null })).toMatchObject({
|
||||||
|
status: "available",
|
||||||
|
windows: [{ id: "plan_usage", usedPct: 0, remainingPct: 100 }],
|
||||||
|
balances: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps dollar-only reports when the response has no percentages", async () => {
|
||||||
|
expect(await reportFor({ totalSpend: 1500, remaining: 2500, limit: 4000 })).toMatchObject({
|
||||||
|
status: "available",
|
||||||
|
windows: [],
|
||||||
|
balances: [{ id: "plan_usage", used: 15, remaining: 25, limit: 40, unit: "usd" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function reportFor(planUsage: Record<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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
toneFromUsedPct,
|
toneFromUsedPct,
|
||||||
usedPctOf,
|
usedPctOf,
|
||||||
unavailable,
|
unavailable,
|
||||||
|
windowFromUsedPct,
|
||||||
type UsageAccount,
|
type UsageAccount,
|
||||||
type UsageReport,
|
type UsageReport,
|
||||||
type UsageBalance,
|
type UsageBalance,
|
||||||
type UsageScope,
|
type UsageScope,
|
||||||
|
type UsageWindow,
|
||||||
} from "@getpaseo/plugin/server/usage";
|
} from "@getpaseo/plugin/server/usage";
|
||||||
|
|
||||||
const ApiNumberSchema = z.coerce.number().finite();
|
const ApiNumberSchema = z.coerce.number().finite();
|
||||||
@@ -60,6 +62,9 @@ const CursorUsageResponseSchema = z.object({
|
|||||||
bonusSpend: ApiNullableNumberSchema,
|
bonusSpend: ApiNullableNumberSchema,
|
||||||
remaining: ApiNullableNumberSchema,
|
remaining: ApiNullableNumberSchema,
|
||||||
limit: ApiNullableNumberSchema,
|
limit: ApiNullableNumberSchema,
|
||||||
|
totalPercentUsed: ApiNullableNumberSchema,
|
||||||
|
autoPercentUsed: ApiNullableNumberSchema,
|
||||||
|
apiPercentUsed: ApiNullableNumberSchema,
|
||||||
})
|
})
|
||||||
.nullish(),
|
.nullish(),
|
||||||
billingCycleStart: CursorBillingCycleTimestampSchema,
|
billingCycleStart: CursorBillingCycleTimestampSchema,
|
||||||
@@ -177,8 +182,29 @@ export async function fetchUsage(
|
|||||||
|
|
||||||
const resp = CursorUsageResponseSchema.parse(await res.json());
|
const resp = CursorUsageResponseSchema.parse(await res.json());
|
||||||
const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd);
|
const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd);
|
||||||
|
const windows: UsageWindow[] = [];
|
||||||
const balances: UsageBalance[] = [];
|
const balances: UsageBalance[] = [];
|
||||||
if (resp.planUsage) {
|
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 totalSpend = centsToDollars(resp.planUsage.totalSpend);
|
||||||
const remaining = centsToDollars(resp.planUsage.remaining);
|
const remaining = centsToDollars(resp.planUsage.remaining);
|
||||||
const limit = centsToDollars(resp.planUsage.limit);
|
const limit = centsToDollars(resp.planUsage.limit);
|
||||||
@@ -197,7 +223,7 @@ export async function fetchUsage(
|
|||||||
return {
|
return {
|
||||||
status: "available",
|
status: "available",
|
||||||
planLabel: undefined,
|
planLabel: undefined,
|
||||||
windows: [],
|
windows,
|
||||||
balances,
|
balances,
|
||||||
details: [],
|
details: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/builtin-plugins",
|
"name": "@getpaseo/builtin-plugins",
|
||||||
"version": "0.13.0-beta.2",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -176,8 +176,9 @@ its registration; that registration belongs in the client entry.
|
|||||||
|
|
||||||
### UI imports in v0.13
|
### UI imports in v0.13
|
||||||
|
|
||||||
Use `/client/ui` for all Paseo UI components and UI hooks. The previous entry remains an
|
Use `/client/ui` for all Paseo UI components and UI hooks. The deprecated
|
||||||
undocumented compatibility alias until 2027-04-20.
|
`/client/react-native` entry retains `useRevealedText(text, phase): string` for 0.12 plugins
|
||||||
|
until removal after 2027-04-20. The hook is deprecated and is not exported from `/client/ui`.
|
||||||
|
|
||||||
For paced timeline text, replace `useRevealedText(text, phase)` and the surrounding `Text` with
|
For paced timeline text, replace `useRevealedText(text, phase)` and the surrounding `Text` with
|
||||||
`<StreamedText text={text} phase={phase} style={style} />` from `/client/ui`.
|
`<StreamedText text={text} phase={phase} style={style} />` from `/client/ui`.
|
||||||
|
|||||||
Reference in New Issue
Block a user