mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56f4af0436 | ||
|
|
9072bce0c3 | ||
|
|
8e5f536766 | ||
|
|
6bc4b73c42 | ||
|
|
631ed0408d | ||
|
|
6521739947 | ||
|
|
6f07b0de48 | ||
|
|
16fe07560f | ||
|
|
2a890208ce | ||
|
|
4bbacfed46 | ||
|
|
8c9499dac6 | ||
|
|
d0fd2e5320 | ||
|
|
5dc350034e | ||
|
|
4845e1da58 | ||
|
|
72e3316f4f | ||
|
|
566218d682 | ||
|
|
b8bb335c56 | ||
|
|
e01a38d943 | ||
|
|
d34d17a907 | ||
|
|
6d655ac1fe | ||
|
|
a54cc6dd11 | ||
|
|
c1b257b8f4 | ||
|
|
f3d74b8575 | ||
|
|
686588c44b | ||
|
|
48623801a1 | ||
|
|
632a7f8859 | ||
|
|
48d2c9ec36 | ||
|
|
ca894d49a5 | ||
|
|
d6fa231894 | ||
|
|
fed19f3d29 | ||
|
|
74dc764f0d | ||
|
|
30d8cb0447 | ||
|
|
d91d61c6eb | ||
|
|
59b01b33f1 | ||
|
|
f3b0199c13 | ||
|
|
582beeb10b | ||
|
|
6405edd8dc | ||
|
|
4b9b2fe130 | ||
|
|
4fe006df86 | ||
|
|
21e7ccb036 | ||
|
|
89727f43bc | ||
|
|
99a1d9a7a2 | ||
|
|
3c3c272925 |
+33
-6
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.13.0-beta.1 - 2026-10-22
|
||||
## 0.13.0 - 2026-10-24
|
||||
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
@@ -14,18 +14,26 @@ paseo host add 'relay://...' --label laptop
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424))
|
||||
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
|
||||
- Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
|
||||
- Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
||||
- Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576))
|
||||
- Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453))
|
||||
- Added pasting images and dropping files into terminals, so agent TUIs such as Claude Code and Codex can read them, including on remote hosts ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
|
||||
|
||||
### Improved
|
||||
|
||||
- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
|
||||
- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690))
|
||||
- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611))
|
||||
- Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580))
|
||||
- Replaced the file tab's Preview/Source toggle with a switcher between the file's panels ([#7411](https://github.com/getpaseo/paseo/pull/7411))
|
||||
- Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424))
|
||||
- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693))
|
||||
- Updated pinned ACP agents to their latest releases ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
|
||||
- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639))
|
||||
- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
||||
- Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
|
||||
@@ -35,25 +43,44 @@ paseo host add 'relay://...' --label laptop
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
|
||||
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614))
|
||||
- Added `useTabState` for per-tab panel state that survives reloads, and `addAgentPanel`; workspace, agent and file panels share the `workspaceId`, `params`, `revision` and `tab` props ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478))
|
||||
- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
||||
- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579))
|
||||
- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368))
|
||||
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474))
|
||||
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
|
||||
- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||
- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630))
|
||||
- Fixed reopened ACP plugin agents showing an empty chat ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
|
||||
- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573))
|
||||
- Fixed parent agents never hearing from `notifyOnFinish` children after a daemon restart ([#7683](https://github.com/getpaseo/paseo/pull/7683))
|
||||
- Fixed opening a large Claude session showing an empty chat or crashing the daemon ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
|
||||
- Fixed Import session crashing the daemon with large Claude histories ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
|
||||
- Fixed hosts staying in Error when the direct connection drops instead of switching to a saved relay connection ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed tapping a notification opening a stale workspace on iOS, and Android reopening the notification's agent on every app reopen ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
|
||||
- Fixed sidebar PR status and checks disappearing after the app reconnects ([#7638](https://github.com/getpaseo/paseo/pull/7638))
|
||||
- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681))
|
||||
- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
|
||||
- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608))
|
||||
- Fixed a new workspace's pin or title disappearing from the sidebar until the next update ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
|
||||
- Fixed Claude background command notifications appearing twice after a daemon restart ([#7737](https://github.com/getpaseo/paseo/pull/7737))
|
||||
- Fixed voice replies skipping later sentences with OpenAI-compatible speech servers ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
|
||||
- Fixed Cursor usage showing bonus spend as overage instead of the quota percentages ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
|
||||
- Fixed the Android tablet taskbar covering the sidebar's Add project, Hosts, Help and Settings ([#7768](https://github.com/getpaseo/paseo/pull/7768))
|
||||
- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688))
|
||||
- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
|
||||
- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
|
||||
- Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538))
|
||||
- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
|
||||
- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429))
|
||||
- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
|
||||
- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362))
|
||||
- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
|
||||
- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365))
|
||||
|
||||
+33
-6
@@ -1,6 +1,6 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.13.0-beta.1 - 2026-10-22
|
||||
## 0.13.0 - 2026-10-24
|
||||
|
||||
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
|
||||
|
||||
@@ -14,18 +14,26 @@ paseo host add 'relay://...' --label laptop
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
|
||||
- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
|
||||
- 新增已连接主机,Agent 可以在其他机器上创建、提示和监控 Agent,并在 Subagents 中查看它们 ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
|
||||
- 新增简体中文版 paseo.sh,地址为 [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
||||
- Changes 窗格新增 All changes 并设为默认,在一个 diff 中显示自分支基点以来已提交和未提交的工作 ([#7576](https://github.com/getpaseo/paseo/pull/7576))
|
||||
- 如果进入 Plan 前开启了 Auto,Claude 计划审批新增 Implement with Auto mode ([#7453](https://github.com/getpaseo/paseo/pull/7453))
|
||||
- 新增在终端中粘贴图片和拖放文件,Claude Code、Codex 等 Agent TUI 可以读取它们,远程主机同样适用 ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
|
||||
|
||||
### 改进
|
||||
|
||||
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
|
||||
- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690))
|
||||
- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611))
|
||||
- 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580))
|
||||
- 文件标签页的 Preview/Source 切换改为在该文件的各个面板之间切换 ([#7411](https://github.com/getpaseo/paseo/pull/7411))
|
||||
- Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
|
||||
- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693))
|
||||
- 已固定版本的 ACP Agent 更新到最新版本 ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
|
||||
- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639))
|
||||
- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
||||
- 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
|
||||
@@ -35,25 +43,44 @@ paseo host add 'relay://...' --label laptop
|
||||
|
||||
### 插件
|
||||
|
||||
- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
|
||||
- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614))
|
||||
- 新增 `useTabState`,用于保存每个标签页的面板状态并在重新加载后恢复,并新增 `addAgentPanel`;工作区、Agent 和文件面板共用 `workspaceId`、`params`、`revision` 和 `tab` 属性 ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- 新增[插件翻译](https://paseo.sh/zh-CN/docs/plugins/reference#runtime-translations),插件内容和标签会跟随应用的语言 ([#7478](https://github.com/getpaseo/paseo/pull/7478))
|
||||
- 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
||||
- `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579))
|
||||
- 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368))
|
||||
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474))
|
||||
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口(包括 `useRevealedText`)仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
|
||||
- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||
- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630))
|
||||
- 修复了重新打开的 ACP 插件 Agent 聊天为空的问题 ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
|
||||
- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573))
|
||||
- 修复了 daemon 重启后父 Agent 收不到 `notifyOnFinish` 子 Agent 通知的问题 ([#7683](https://github.com/getpaseo/paseo/pull/7683))
|
||||
- 修复了打开大型 Claude 会话时聊天为空或 daemon 崩溃的问题 ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
|
||||
- 修复了 Claude 历史记录较大时导入会话导致 daemon 崩溃的问题 ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
|
||||
- 修复了直连断开后主机停留在错误状态、不切换到已保存的 relay 连接的问题 ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- 修复了 iOS 上点击通知打开过期工作区,以及 Android 每次重新打开应用都会再次打开通知对应 Agent 的问题 ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
|
||||
- 修复了应用重新连接后侧边栏中的 PR 状态和检查消失的问题 ([#7638](https://github.com/getpaseo/paseo/pull/7638))
|
||||
- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681))
|
||||
- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
|
||||
- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608))
|
||||
- 修复了新工作区的置顶或标题在下次更新前从侧边栏消失的问题 ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
|
||||
- 修复了 daemon 重启后 Claude 后台命令通知出现两次的问题 ([#7737](https://github.com/getpaseo/paseo/pull/7737))
|
||||
- 修复了使用 OpenAI 兼容语音服务时语音回复跳过后续句子的问题 ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
|
||||
- 修复了 Cursor 用量将赠送额度消费显示为超额、而不是配额百分比的问题 ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
|
||||
- 修复了 Android 平板任务栏遮挡侧边栏中添加项目、主机、帮助和设置的问题 ([#7768](https://github.com/getpaseo/paseo/pull/7768))
|
||||
- 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688))
|
||||
- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
|
||||
- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547))
|
||||
- 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538))
|
||||
- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
|
||||
- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429))
|
||||
- 修复了 Android 上不受信任的证书显示 "Host not found"、无法解析的主机名显示 TLS 建议的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
|
||||
- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362))
|
||||
- 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
|
||||
- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365))
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ The daemon requires a valid cryptographic handshake before processing any comman
|
||||
|
||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||
|
||||
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
|
||||
When a daemon password is configured, relay clients send it in the encrypted `hello` message, so the relay never sees it. The daemon rejects a relay client that sends a wrong password or none, so a leaked pairing link alone does not grant access to a password-protected daemon.
|
||||
|
||||
## Local daemon trust boundary
|
||||
|
||||
|
||||
+73
-4
@@ -25,8 +25,54 @@ Reload releases the old runtime before resuming its durable session: an idle pro
|
||||
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
|
||||
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
|
||||
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
Idle resumable runtimes are collected after one hour without activity, checked once a minute.
|
||||
Collection releases the process and preserves the agent record, conversation, relationships, and
|
||||
provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in
|
||||
host config to disable automatic collection. The host setting applies immediately; config-file edits
|
||||
apply on reload. Disabling invalidates pending safety checks and close admission. A close already
|
||||
started finishes. Re-enabling keeps the existing idle timestamps.
|
||||
|
||||
Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working
|
||||
managed or native descendants prevent collection. Outstanding local or remote finish subscriptions
|
||||
also protect their caller, including registration, recovery and notification dispatch acknowledgment.
|
||||
An idle managed child can still own background work.
|
||||
|
||||
The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false,
|
||||
errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider
|
||||
check, the baseline decides collection after the same one-hour idle period. Once protected work
|
||||
finishes, a fresh idle hour starts. There is no deadline that overrides a veto.
|
||||
|
||||
The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime.
|
||||
AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown.
|
||||
Finish subscriptions expose caller protection through their public capability; the collector never
|
||||
reads their watches or durable records.
|
||||
|
||||
The collector rechecks daemon state after asynchronous probes and event draining, in the existing
|
||||
close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving
|
||||
after closure starts waits for release and resumes the saved session.
|
||||
|
||||
Provider checks follow the signals their runtimes expose:
|
||||
|
||||
- Claude uses its process-local background roster and pending completion notifications. A fresh
|
||||
process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must
|
||||
demonstrate support by emitting one. Unknown versions stay resident until then.
|
||||
- Codex queries background terminals for loaded threads, including native children, and protects
|
||||
pending provider requests. App servers without the terminal-list RPC stay resident.
|
||||
- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and
|
||||
inbox inventories. The shared server remains leased while a session vetoes collection.
|
||||
- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native
|
||||
subagent signals also protect their parent through the daemon's subagent store.
|
||||
- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`.
|
||||
These protect jobs with unsuppressed completion delivery, pending deliveries, queued result
|
||||
injection, and admitted or scheduled continuations that can re-wake the session. Older OMP
|
||||
runtimes without either field stay resident. These signals are not an inventory of arbitrary
|
||||
processes.
|
||||
- ACP checks adapter-owned foreground turns, permissions, buffered user messages, and running
|
||||
ACP terminals, including quiet terminals. Exited or failed-to-spawn terminal records do not
|
||||
prevent collection. This does not inspect processes launched internally by the provider.
|
||||
|
||||
These checks do not discover arbitrary detached processes or work hidden by third-party
|
||||
extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
|
||||
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
|
||||
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
|
||||
@@ -79,7 +125,30 @@ Users can also detach an existing subagent from the subagents track. Detach is d
|
||||
|
||||
Agent-originated messages and automatic notifications share a recipient-daemon delivery module. A queued receipt acknowledges admission before provider dispatch, allowing a child to report and finish in the same two-second quiet window. Human prompts dispatch directly. Conflicting explicit interruption or session-mode choices retain separate ordered dispatches.
|
||||
|
||||
The pending window is in memory. Clean shutdown flushes it before closing agents; an abrupt crash can lose it. A durable send receipt remains pending until actual dispatch acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued admission within their original timeout and cancellation scope.
|
||||
The pending message window is in memory. Clean shutdown flushes it before closing agents; an abrupt
|
||||
crash can lose queued prompts. A durable send receipt remains pending until actual dispatch
|
||||
acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued
|
||||
admission within their original timeout and cancellation scope.
|
||||
|
||||
Finish notification intent has a separate daemon-owned lifetime. The finish-subscriptions module
|
||||
persists caller, child, host, and ownership policy through an injectable store; its JSON adapter uses
|
||||
`$PASEO_HOME/finish-subscriptions.json`. Registration must commit before creation or queued-prompt
|
||||
acknowledgment promises a notification. A pending replacement retains its predecessor until dispatch
|
||||
accepts; rejected dispatch removes only the candidate. Recovery coalesces registrations for the same
|
||||
caller, child, and host into one watch.
|
||||
|
||||
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
|
||||
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
|
||||
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
|
||||
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
|
||||
reporting after detachment.
|
||||
|
||||
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
|
||||
retries the captured notification; it does not change the child's reported outcome. Remote host loss
|
||||
follows the [connected-host observation policy](connected-hosts.md#delegating-agents). Shutdown stops observation before flushing message delivery, retaining
|
||||
outstanding intent. A crash after successful dispatch but before removal commits can repeat the
|
||||
notification. Clean shutdown can also flush an already queued callback while retaining its intent,
|
||||
so that callback can repeat after restart. This is an at-least-once wakeup, without turn attribution or exactly-once delivery.
|
||||
|
||||
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
|
||||
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
|
||||
@@ -219,7 +288,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
+19
-4
@@ -44,10 +44,25 @@ connection candidates, including direct connections and routes discovered throug
|
||||
Adding a direct connection to a discovered host adds another candidate. If no candidate connects,
|
||||
the destination workspace keeps its existing disconnected or reconnecting behavior.
|
||||
|
||||
Finish notifications use the existing idle wait, including a pending initial run or an already idle
|
||||
child. They name the server and agent. A daemon restart or route loss ends an outstanding notification
|
||||
wait; inspect or wait for the child again rather than resending its prompt. Concurrent prompts gain
|
||||
no additional turn attribution or delivery guarantee.
|
||||
Finish notifications name the destination server and child agent. The caller's daemon owns durable
|
||||
subscription intent and restores observation through the same routed wait and permission observation
|
||||
used during ordinary operation. Restarting the caller's daemon does not imply that a remote child
|
||||
stopped. Three failed remote observation attempts end the subscription with a notification that the
|
||||
child could not be observed and its status is unknown. This does not prove the host is offline: remote
|
||||
RPC failures also prevent observation. Attempts use capped backoff; a connection still
|
||||
in progress does not spend that budget. A confirmed removed registration reports host unavailability
|
||||
immediately through the same path. Neither notice claims the child finished. Intent remains stored
|
||||
until the parent accepts dispatch, then is consumed; reconnecting does not restore that watch. The
|
||||
parent decides whether to inspect the child or delegate again.
|
||||
|
||||
A definitely unsent prompt returns a tool error and creates no notification. An ambiguous prompt
|
||||
response retains intent because the destination may have admitted it; observation then follows the
|
||||
same retry policy. A confirmed missing child reports unavailability.
|
||||
|
||||
Remote creation and local subscription persistence are separate commits. A failed local write reports
|
||||
failure even if the destination already created the child; inspect it before retrying creation.
|
||||
See [finish notification lifetime](agent-lifecycle.md#relationships) for registration, acknowledgment,
|
||||
and duplicate-delivery semantics. Concurrent prompts gain no turn attribution.
|
||||
|
||||
## Connections and recovery
|
||||
|
||||
|
||||
@@ -43,13 +43,16 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh
|
||||
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
|
||||
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
|
||||
|
||||
Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag
|
||||
Sheets are modal and cover both compact sidebars as well as the workspace. A sheet's header is its drag
|
||||
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
|
||||
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
|
||||
Close, so the title keeps its width. The sheets present
|
||||
from the shared workspace's `SheetHostScope`, so their panels read the navigator and every
|
||||
workspace provider an ordinary pane has without a context bridge
|
||||
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
|
||||
Close, so the title keeps its width. Compact sheets use the app overlay host: a workspace-local
|
||||
host sits behind the app-level left sidebar. The workspace environment bridge preserves existing
|
||||
editing sessions, caches, file-link resolution and supported navigation context without moving
|
||||
their providers. Keep this capability at the workspace boundary; new sheet callers must not
|
||||
assemble provider internals. Wide layouts retain local hosting and pane geometry. Both hosts
|
||||
forward the same Gorhom modal queue; neither creates another stack. See
|
||||
[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site).
|
||||
|
||||
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
|
||||
Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
|
||||
|
||||
+34
-18
@@ -115,11 +115,10 @@ quietly relying on:
|
||||
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
||||
`isPaneFocused` prop already exists and flips on pane switches; pass
|
||||
`visible={isYourOwnVisible && isPaneFocused}`.
|
||||
- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift
|
||||
SharedValue, and `useKeyboardShiftStyle()` only adapts that value into
|
||||
translate/padding styles. The composer and chat content must both read that
|
||||
provider-owned value. A portal'd popover is outside the composer tree — it
|
||||
does not get that transform unless you apply it yourself.
|
||||
- **Transforms.** The composer moves with the keyboard through a
|
||||
`KeyboardTranslateView` transform. A portal'd popover is outside that view, so
|
||||
it does not move with the composer unless it renders its own
|
||||
`KeyboardTranslateView` with the same translation.
|
||||
- **Layering.** The default root host renders after app content, so it sits
|
||||
above compact sidebars. Content overlays that must sit below sidebars should
|
||||
use the current `FloatingPanelPortalHost`.
|
||||
@@ -158,7 +157,7 @@ and the stream keeps its existing flick-to-dismiss gesture.
|
||||
The dock's private viewport and capacity model are extracted from chat. The
|
||||
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
|
||||
terminal, explorer, and dialogs. Import only its public entry point: the provider,
|
||||
shift hooks, shift style, translate view, settled stream inset, and explorer padding
|
||||
shift hooks, shift style, translate view, keyboard anchor, settled stream inset, and explorer padding
|
||||
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
|
||||
at its root mount point outside the portal host when changing dock hosts.
|
||||
|
||||
@@ -256,11 +255,27 @@ portal geometry, plus the `isMoving` worklet value. It reconciles native
|
||||
animation-end events because the controller can retain a nonzero value after
|
||||
the keyboard closes.
|
||||
|
||||
Measure portal anchors while the dock is at rest. If a popover opens during
|
||||
motion, re-measure when `isMoving` settles. `measureInWindow` includes the
|
||||
dock's current layout and transform. Snapshot the shift at measurement time and
|
||||
apply only the subsequent shift delta to the animated `bottom`. Adding the full
|
||||
shift moves the portal twice and can place it over the composer controls.
|
||||
Anchor a portal to content inside a `KeyboardTranslateView` with
|
||||
`useKeyboardAnchor()`. Read it on the anchor's side of the Portal: the
|
||||
translation scope is React context and does not cross it (Gotcha 7). It returns:
|
||||
|
||||
- `measureAtRest(element)`: the element's window rect with the keyboard
|
||||
translation left out, or null once the anchor unmounts. Lay the popover out
|
||||
from this rect. It assumes the translate view's parent does not move with the
|
||||
keyboard, which holds for the composer dock.
|
||||
- `translation`: spread it onto a `KeyboardTranslateView` inside the Portal, so
|
||||
the popover moves with the anchor on the same driver, including centered
|
||||
clearance and disabled translation.
|
||||
- `reservedLift`: the anchor's lift at keyboard boundaries. Bound the popover's
|
||||
height with it; never animate height per frame.
|
||||
|
||||
Do not measure an anchor with `measureInWindow` and correct it by the keyboard
|
||||
shift. On iOS the translate view uses the native driver, and `measureInWindow`
|
||||
includes its transform only when the view happened to re-render since the
|
||||
keyboard moved. Both results were observed, so any correction double-lifts the
|
||||
popover or leaves it behind the keyboard. `measureAtRest` measures through
|
||||
Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving`
|
||||
settles; the composer's resting position changes as the draft grows.
|
||||
|
||||
The provider also reconciles iOS from the controller's native `onEnd` event.
|
||||
The controller's stock iOS shared values update at move start and during an
|
||||
@@ -273,7 +288,8 @@ not be required to repair the offset.
|
||||
|
||||
The generic anchored-overlay rule is:
|
||||
|
||||
1. Measure the anchor with `measureInWindow`.
|
||||
1. Measure the anchor with `measureInWindow`, or with `measureAtRest` when it
|
||||
moves with the keyboard (Gotcha 3).
|
||||
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
|
||||
3. Position with anchor coordinates relative to the host:
|
||||
|
||||
@@ -347,10 +363,10 @@ track phase.
|
||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no
|
||||
exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on
|
||||
close. This includes native centered dialogs: separate React Native Modals cannot present a
|
||||
root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree
|
||||
(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their
|
||||
panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the
|
||||
portal host and container measurement while the sheet still joins the same stack. That export is
|
||||
root-owned sibling dialog over an existing iOS presenter. `SheetHostScope` moves the portal host
|
||||
and container measurement while forwarding the same modal queue. Compact
|
||||
[workspace sheets](explorer-sidebar.md#sheets) use the app overlay host above both sidebars;
|
||||
wide layouts keep local geometry. That export is
|
||||
what `patches/@gorhom+bottom-sheet+*.patch` adds.
|
||||
|
||||
A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every
|
||||
@@ -405,8 +421,8 @@ Before you write a new one, ask:
|
||||
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
|
||||
raw window coordinates as local Portal coordinates.
|
||||
4. **Does the panel sit above something that moves with the keyboard?** If
|
||||
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
|
||||
If no, you can probably skip the transform entirely.
|
||||
yes, anchor it with `useKeyboardAnchor()` and wrap it in a
|
||||
`KeyboardTranslateView` with the anchor's translation (Gotcha 3).
|
||||
5. **Will the panel's content height vary?** If yes, you need both
|
||||
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
|
||||
null until anchor, then opacity-0 until contentSize). If no — content has
|
||||
|
||||
+31
-1
@@ -53,7 +53,18 @@ hold-to-delete and background-tap dismissal scenario on chat, a workspace draft
|
||||
tab, and New workspace. It checks empty-content taps and header taps at the cap
|
||||
without changing the input height. Each
|
||||
host also opens command/file autocomplete, the model selector, the attachment
|
||||
menu, and the forge picker from both keyboard states. It then submits a long
|
||||
menu, and the forge picker from both keyboard states. Autocomplete opens with
|
||||
the keyboard closed, before the keyboard opens, and after it settles. Each time
|
||||
the expected best match must be drawn whole between the header and the composer,
|
||||
above the IME frame, and tapping it there must produce the exact composer text.
|
||||
Accessibility reports rows behind the keyboard too, so
|
||||
`packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs` compares
|
||||
bounds. Android leaves the chat and composer out of the accessibility tree while
|
||||
suggestions are open, so the composer bounds come from a snapshot with an empty
|
||||
input in the same keyboard state. The queries are partial names with known best
|
||||
matches: in chat `/mo` finds the mock provider's `/mock` command, in drafts
|
||||
`/release` finds this repository's `release-beta` skill for the first Claude model,
|
||||
and `@app` finds `packages/app`. It then submits a long
|
||||
fixture from each host and checks native stream displacement twice: after the
|
||||
response settles, and after returning to the bottom and letting the scrollbar
|
||||
fade. The image comparison excludes the scrollbar; keyboard dismissal alone
|
||||
@@ -64,6 +75,25 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
|
||||
`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard;
|
||||
the headless input helper cannot verify the
|
||||
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
|
||||
To rerun only the autocomplete, model selector, attachment menu, and forge picker
|
||||
checks on each host, skipping the chat baseline and the growth, dismissal, and
|
||||
stream scenarios:
|
||||
|
||||
```bash
|
||||
PASEO_COMPOSER_KEYBOARD_SCENARIO=overlays npm run test:e2e:composer-keyboard:android
|
||||
```
|
||||
|
||||
`node packages/app/e2e/mobile/composer-keyboard/ios.mjs` runs the same
|
||||
autocomplete checks on iOS for chat, a workspace draft tab, and New workspace,
|
||||
with commands, files, and files under a grown draft. Suggestions open while the
|
||||
keyboard opens (`fill` taps and types at once), after it settles, with it closed,
|
||||
and before it reopens. `test:e2e:mobile` cannot host this: its `.ad` flows assert
|
||||
accessibility state and cannot compare bounds. Run it where `agent-device`
|
||||
reaches the simulator, with the development app connected to the fixture host
|
||||
and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`,
|
||||
`_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`,
|
||||
`_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to
|
||||
`.dev/agent-device-artifacts/composer-keyboard-ios`.
|
||||
|
||||
The Android sidebar scroll-retention flow requires a connected host with enough
|
||||
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
|
||||
|
||||
+6
-1
@@ -498,6 +498,11 @@ Validate and apply it inside the provider; core does not know your option shape.
|
||||
See [provider options](custom-providers.md#provider-options) for configuration and
|
||||
merge semantics.
|
||||
|
||||
Providers add an optional veto to daemon baseline cleanup through the per-session
|
||||
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
|
||||
the plugin bridge transports that query through the existing provider request/event protocol.
|
||||
|
||||
Agent refresh closes the current provider session and opens it again with current configuration and
|
||||
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
|
||||
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
|
||||
@@ -570,7 +575,7 @@ and resolves conflicts. The host has no bar and no notion of a panel's views: th
|
||||
panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the
|
||||
dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in
|
||||
claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's
|
||||
editor) take the same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
|
||||
editor) take exactly `PluginFilePanelProps`, including params and persisted tab state, so a fix to reading, watching, or writing lands in one place:
|
||||
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
|
||||
make that panel faster instead. Source highlights a large file on native in idle-time slices,
|
||||
the lines around the opening line first.
|
||||
|
||||
@@ -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-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
|
||||
sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -35695,7 +35695,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37961,12 +37961,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.13.0-beta.1",
|
||||
"@getpaseo/protocol": "0.13.0-beta.1",
|
||||
"@getpaseo/server": "0.13.0-beta.1",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/server": "0.13.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -38213,10 +38213,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.13.0-beta.1",
|
||||
"@getpaseo/relay": "0.13.0-beta.1",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/relay": "0.13.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38228,7 +38228,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38472,7 +38472,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -42124,7 +42124,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -42153,15 +42153,15 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.13.0-beta.1",
|
||||
"@getpaseo/protocol": "0.13.0-beta.1",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -42171,8 +42171,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.13.0-beta.1",
|
||||
"@getpaseo/protocol": "0.13.0-beta.1",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -42194,7 +42194,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -42221,7 +42221,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
@@ -42442,16 +42442,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.13.0-beta.1",
|
||||
"@getpaseo/highlight": "0.13.0-beta.1",
|
||||
"@getpaseo/plugin": "0.13.0-beta.1",
|
||||
"@getpaseo/protocol": "0.13.0-beta.1",
|
||||
"@getpaseo/relay": "0.13.0-beta.1",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/highlight": "0.13.0",
|
||||
"@getpaseo/plugin": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/relay": "0.13.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -43372,7 +43372,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -44982,7 +44982,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -420,3 +420,40 @@ test.describe("Add Project command-center flow", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Compact Add project context", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
test("canceling Add project preserves the sidebar", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await openAddProjectFlow(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProjectFlow(page)).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible();
|
||||
});
|
||||
test("successful Add project reveals the new workspace page", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await openAddProjectFlow(page);
|
||||
await chooseAddProjectMethod(page, "directory-search");
|
||||
await addProjectFlowInput(page).fill(projectPickerFixture.fuzzyQuery);
|
||||
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName);
|
||||
await page.keyboard.press("Enter");
|
||||
const projectId = await expectOpenedProject(page, projectPickerFixture.projectName);
|
||||
projectPickerFixture.rememberProjectId(projectId);
|
||||
await expectNewWorkspaceForAddedProject(page, {
|
||||
serverId: getServerId(),
|
||||
projectId,
|
||||
projectName: projectPickerFixture.projectName,
|
||||
projectPath: projectPickerFixture.projectPath,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function readFileLinkTooltip(page: Page): Promise<void> {
|
||||
const link = page.getByRole("link", { name: "the guide", exact: true });
|
||||
await link.hover();
|
||||
await expect(page.getByRole("tooltip")).toContainText("guide.md");
|
||||
}
|
||||
|
||||
async function leaveFileLink(page: Page): Promise<void> {
|
||||
await page.mouse.move(5, 5);
|
||||
await expect(page.getByRole("tooltip")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function openLinkedFile(page: Page): Promise<void> {
|
||||
await page.getByRole("link", { name: "the guide", exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
|
||||
await leaveFileLink(page);
|
||||
}
|
||||
|
||||
async function returnToAgent(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Tooltip regression", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "the guide", exact: true })).toBeVisible();
|
||||
await leaveFileLink(page);
|
||||
}
|
||||
|
||||
async function switchToFileTab(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "guide.md", exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
|
||||
await leaveFileLink(page);
|
||||
}
|
||||
|
||||
test("opening an assistant file link dismisses its tooltip over the file tab", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "tooltip-5811-",
|
||||
title: "Tooltip regression",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: "Read [the guide](./guide.md).",
|
||||
mockStreamingAssistantIntervalMs: 5,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(agent.cwd, "guide.md"),
|
||||
"# Example guide\n\n" + "Plain paragraph.\n\n".repeat(150),
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the file link.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await readFileLinkTooltip(page);
|
||||
await leaveFileLink(page);
|
||||
await readFileLinkTooltip(page);
|
||||
await openLinkedFile(page);
|
||||
await returnToAgent(page);
|
||||
await readFileLinkTooltip(page);
|
||||
await switchToFileTab(page);
|
||||
await returnToAgent(page);
|
||||
await readFileLinkTooltip(page);
|
||||
await leaveFileLink(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1415,12 +1415,14 @@ test("collapsing and expanding restores the real horizontal scroll offset", asyn
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
if (localStorage.getItem(settingsKey)) {
|
||||
const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}");
|
||||
if (settings.codeFontSize !== undefined) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({
|
||||
...settings,
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
|
||||
@@ -2,11 +2,17 @@ import { expect, test } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
delayDirectorySuggestionResponses,
|
||||
holdDirectorySuggestionResponses,
|
||||
expectOneLineTruncatedFileResult,
|
||||
expectStableCommandCenterLayout,
|
||||
failDirectorySuggestionRequests,
|
||||
startCommandCenterLayoutObservation,
|
||||
} from "../support/helpers/command-center-file-search";
|
||||
import {
|
||||
scrollCommandCenterAwayFromSelection,
|
||||
expectCommandCenterScrollToStayAt,
|
||||
expectKeyboardNavigationToResume,
|
||||
} from "../support/helpers/command-center-scroll";
|
||||
import { expectFileTabOpen } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
@@ -137,3 +143,94 @@ test("unscoped command search keeps commands visible and reports file-search fai
|
||||
await Promise.allSettled(seeded.map((workspace) => workspace.cleanup()));
|
||||
}
|
||||
});
|
||||
|
||||
test("manual scrolling cancels a pending file-search reveal and query editing restores it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-delayed-scroll-",
|
||||
title: "Delayed file search scroll",
|
||||
repo: { files: paseoSizedFiles() },
|
||||
});
|
||||
try {
|
||||
const responses = await holdDirectorySuggestionResponses(page);
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await page.keyboard.press("Meta+P");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible();
|
||||
await panel.getByTestId("command-center-input").fill("index");
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await expect(
|
||||
panel.getByRole("button", {
|
||||
name: "index.ts packages/app/src/features/feature-000",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
responses.hold();
|
||||
await panel.getByTestId("command-center-input").fill("index.");
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
|
||||
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
|
||||
await responses.release();
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await expectCommandCenterScrollToStayAt(panel, offset);
|
||||
await expectKeyboardNavigationToResume(page, panel, offset);
|
||||
|
||||
const nextOffset = await scrollCommandCenterAwayFromSelection(page, panel);
|
||||
await panel.getByTestId("command-center-input").fill("index.ts");
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await expect
|
||||
.poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop))
|
||||
.toBeLessThan(nextOffset);
|
||||
await expect(
|
||||
panel.getByRole("button", {
|
||||
name: "index.ts packages/app/src/features/feature-000",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Meta+P");
|
||||
await expect(panel.getByTestId("command-center-input")).toHaveValue("");
|
||||
await panel.getByTestId("command-center-input").fill("index");
|
||||
await expect(
|
||||
panel.getByRole("button", {
|
||||
name: "index.ts packages/app/src/features/feature-000",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Compact file search context", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
test("a file result opens a sheet above the left sidebar", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "compact-search-file-",
|
||||
title: "Compact file lookup",
|
||||
repo: { files: [{ path: "lookup.ts", content: "export const lookup = true;\n" }] },
|
||||
});
|
||||
try {
|
||||
await page.goto(`/h/${process.env.E2E_SERVER_ID}/workspace/${seeded.workspaceId}`);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
const search = await openCommandCenter(page);
|
||||
await search.getByTestId("command-center-input").fill("lookup.ts");
|
||||
await search.getByRole("button", { name: "lookup.ts", exact: true }).click();
|
||||
await expect(page.getByTestId("sheet-tab-body")).toBeVisible();
|
||||
await expect(page.getByTestId("sheet-tab-header")).toContainText("lookup.ts");
|
||||
await page
|
||||
.getByTestId("sheet-tab-header")
|
||||
.getByRole("button", { name: "Close", exact: true })
|
||||
.click();
|
||||
await expect(page.getByTestId("sheet-tab-body")).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible();
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
seedSearchableCommandCenter,
|
||||
observeCommandCenterRowRenders,
|
||||
searchCommandCenter,
|
||||
warmCommandCenterRows,
|
||||
expectOnlyCommandCenterRowRendered,
|
||||
expectCommandCenterAgentWindow,
|
||||
scrollToOldestCommandCenterAgent,
|
||||
} from "../support/helpers/command-center-performance";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
expectCommandCenterToRemainAtTheTop,
|
||||
scrollCommandCenterAwayFromSelection,
|
||||
expectCommandCenterScrollToStayAt,
|
||||
expectKeyboardNavigationToResume,
|
||||
expectPrimaryCommandCenterActions,
|
||||
expectTightTwoLineResultSpacing,
|
||||
expectVisibleKeyboardNavigationNotToScroll,
|
||||
@@ -16,12 +30,15 @@ test.use({
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
|
||||
});
|
||||
|
||||
test("opening the command center keeps its first result fully visible", async ({ page }) => {
|
||||
test("Command Center preserves manual scrolling through live updates and resumes keyboard navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-scroll-",
|
||||
title: "Command Center Scroll 00",
|
||||
});
|
||||
|
||||
let newestWorkspaceId = seeded.workspaceId;
|
||||
try {
|
||||
for (let index = 1; index <= 8; index += 1) {
|
||||
const created = await seeded.client.createWorkspace({
|
||||
@@ -36,6 +53,7 @@ test("opening the command center keeps its first result fully visible", async ({
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to seed workspace ${index}`);
|
||||
}
|
||||
newestWorkspaceId = created.workspace.id;
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
@@ -48,6 +66,52 @@ test("opening the command center keeps its first result fully visible", async ({
|
||||
// The empty query lists the most recent workspaces, so the last one seeded leads it.
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
|
||||
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
|
||||
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
|
||||
await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed");
|
||||
await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1);
|
||||
await expectCommandCenterScrollToStayAt(panel, offset);
|
||||
await expectKeyboardNavigationToResume(page, panel, offset);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("live search updates render only the changed row and keep agents virtualized", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const seeded = await seedSearchableCommandCenter();
|
||||
try {
|
||||
await observeCommandCenterRowRenders(page);
|
||||
await gotoAppShell(page);
|
||||
const panel = await openCommandCenterWithKeyboard(page);
|
||||
await searchCommandCenter(panel, "Search agent");
|
||||
await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible();
|
||||
await warmCommandCenterRows(page);
|
||||
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
|
||||
|
||||
await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" });
|
||||
await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible();
|
||||
await expectOnlyCommandCenterRowRendered(
|
||||
page,
|
||||
`agent:${getServerId()}:${seeded.agents[59].id}`,
|
||||
);
|
||||
|
||||
await searchCommandCenter(panel, "Search workspace");
|
||||
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
|
||||
await warmCommandCenterRows(page);
|
||||
await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised");
|
||||
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
|
||||
await expectOnlyCommandCenterRowRendered(
|
||||
page,
|
||||
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
|
||||
);
|
||||
|
||||
await searchCommandCenter(panel, "Search agent");
|
||||
await warmCommandCenterRows(page);
|
||||
await scrollToOldestCommandCenterAgent(panel);
|
||||
await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible();
|
||||
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedRunningMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
@@ -20,6 +21,34 @@ const AGENT_TITLE = "Fix checkout retries";
|
||||
test.describe("Command center workspaces", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent and workspace results retain their icons and overlay live status", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedRunningMockAgentWorkspace({
|
||||
repoPrefix: "command-center-status-",
|
||||
title: "Review checkout retries",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "Keep working on checkout retries",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectAgentTabActive(page, workspace.agentId);
|
||||
const panel = await openCommandCenter(page);
|
||||
const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`);
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${workspace.workspaceId}`,
|
||||
);
|
||||
await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible();
|
||||
await workspace.client.cancelAgent(workspace.agentId);
|
||||
await expect(agent.getByRole("progressbar")).toHaveCount(0);
|
||||
await expect(row.getByRole("progressbar")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace results show their title, host, and branch and open the workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -156,3 +185,74 @@ test.describe("Command center workspaces", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Compact sidebar inspection", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
async function openSidebar(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectSidebarPreserved(page: import("@playwright/test").Page) {
|
||||
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible();
|
||||
}
|
||||
|
||||
test("canceling Search retains the compact sidebar", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSidebar(page);
|
||||
await openCommandCenter(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible();
|
||||
await expectSidebarPreserved(page);
|
||||
});
|
||||
test("canceling Import session retains the compact sidebar", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSidebar(page);
|
||||
await page
|
||||
.getByTestId("sidebar-project-empty-state")
|
||||
.getByRole("button", { name: "Import session", exact: true })
|
||||
.click();
|
||||
await expect(page.getByTestId("import-session-sheet")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(page.getByTestId("import-session-sheet")).not.toBeVisible();
|
||||
await expectSidebarPreserved(page);
|
||||
});
|
||||
|
||||
test("Search reveals the same workspace and a selected root agent", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "compact-search-destination-",
|
||||
title: "Compact destination",
|
||||
});
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Compact root agent",
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), seeded.workspaceId));
|
||||
await openSidebar(page);
|
||||
const search = await openCommandCenter(page);
|
||||
await search.getByTestId("command-center-input").fill("Compact destination");
|
||||
await search
|
||||
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId));
|
||||
await openSidebar(page);
|
||||
const agentSearch = await openCommandCenter(page);
|
||||
await agentSearch.getByTestId("command-center-input").fill("Compact root agent");
|
||||
await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click();
|
||||
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Message agent...", exact: true }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +99,16 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
});
|
||||
|
||||
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
|
||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(childSheet).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
function userMessages(page: Page) {
|
||||
return page.getByTestId("user-message");
|
||||
}
|
||||
|
||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||
await submitMessage(page, text);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
|
||||
async function pressInComposer(page: Page, key: string) {
|
||||
await composerLocator(page).press(key);
|
||||
}
|
||||
|
||||
test.describe("Composer recall", () => {
|
||||
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-",
|
||||
title: "Composer recall",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await pressInComposer(page, "Enter");
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a recalled slash message does not open the command picker", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-slash-",
|
||||
title: "Composer recall slash",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 1);
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -182,6 +182,7 @@ test("captures the compact import-session journey", async ({ page }, testInfo) =
|
||||
await test.step("the selected row imports and opens the hydrated transcript", async () => {
|
||||
await flow.importSession(scenario.importSessionId);
|
||||
await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready.");
|
||||
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
|
||||
await capture(page, testInfo, "08-mobile-agent-after-import.png");
|
||||
});
|
||||
|
||||
@@ -228,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> {
|
||||
const outputPath = testInfo.outputPath(name);
|
||||
await page.screenshot({ path: outputPath, fullPage: true });
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
delayBrowserAgentCreatedStatus,
|
||||
openNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
// The creation response carries the descriptor captured when the workspace was provisioned. With an
|
||||
// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's
|
||||
// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The
|
||||
// late response must not put the old, unpinned row back.
|
||||
|
||||
function pinnedRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise<string> {
|
||||
let createdId: string | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { entries } = await seeded.client.fetchWorkspaces({
|
||||
filter: { projectId: seeded.projectId },
|
||||
});
|
||||
createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id;
|
||||
return createdId;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeTruthy();
|
||||
return createdId!;
|
||||
}
|
||||
|
||||
// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts.
|
||||
async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise<number>> {
|
||||
await page.evaluate((rowTestId) => {
|
||||
const state = { missingFrames: 0, stopped: false };
|
||||
(window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state;
|
||||
const sample = () => {
|
||||
if (state.stopped) return;
|
||||
const section = document.querySelector('[data-testid="sidebar-pinned-section"]');
|
||||
if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1;
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
}, testId);
|
||||
return () =>
|
||||
page.evaluate(() => {
|
||||
const state = (
|
||||
window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } }
|
||||
).__pinnedRowWatch;
|
||||
state.stopped = true;
|
||||
return state.missingFrames;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("New workspace live pin", () => {
|
||||
test("keeps a pin made before the creation response arrives", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const creation = await delayBrowserAgentCreatedStatus(page);
|
||||
const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, seeded);
|
||||
await submitNewWorkspacePrompt(page, "Pin me while I start");
|
||||
|
||||
await creation.waitForCreateRequest();
|
||||
// The response is held here, carrying the descriptor from before the pin.
|
||||
await creation.waitForDelayedCreatedStatus();
|
||||
const workspaceId = await findCreatedWorkspaceId(seeded);
|
||||
const rowTestId = pinnedRowTestId(workspaceId);
|
||||
|
||||
await seeded.client.setWorkspacePinned(workspaceId, true);
|
||||
const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId);
|
||||
await expect(pinnedRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const stopWatching = await watchPinnedRow(page, rowTestId);
|
||||
creation.release();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
|
||||
timeout: 30_000,
|
||||
});
|
||||
// Leave time for the released response to be merged and rendered.
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
expect(await stopWatching()).toBe(0);
|
||||
await expect(pinnedRow).toBeVisible();
|
||||
} finally {
|
||||
creation.release();
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -105,6 +105,17 @@ async function selectFileView(page: Page, view: "Preview" | "Source") {
|
||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
async function rememberMarkdownViewAcrossReload(page: Page, view: "Preview" | "Source") {
|
||||
const button = page.getByRole("button", { name: `Rich Markdown ${view}`, exact: true });
|
||||
await button.click();
|
||||
await page.reload();
|
||||
if (view === "Source") {
|
||||
await expectMarkdownEditor(page, "Edited by Rich Markdown");
|
||||
} else {
|
||||
await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
|
||||
async function pdfViewer(page: Page): Promise<Frame> {
|
||||
const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/;
|
||||
@@ -220,6 +231,9 @@ test("directory examples mount the PDF frame and write Markdown through the file
|
||||
await expectMarkdownEditor(page, "Edited by Rich Markdown");
|
||||
});
|
||||
|
||||
await rememberMarkdownViewAcrossReload(page, "Preview");
|
||||
await rememberMarkdownViewAcrossReload(page, "Source");
|
||||
|
||||
// "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the
|
||||
// cursor and vim mode in the panel's toolbar.
|
||||
await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo");
|
||||
|
||||
@@ -136,7 +136,8 @@ test.describe("real provider subagent timelines", () => {
|
||||
panel.getByText("Start chatting with this agent...", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
|
||||
// A provider subagent has no composer; its callout is the way back to the parent.
|
||||
await page.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fork } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { expect } from "@playwright/test";
|
||||
import { expect, type BrowserContext, type Locator, type Page } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
import { test } from "../support/fixtures";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
@@ -34,8 +34,218 @@ const ROLLOUT_QUESTION = "Which rollout should we use?";
|
||||
const SUCCESS_QUESTION = "What success criteria should we use?";
|
||||
const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?";
|
||||
const COMMIT_MESSAGE_QUESTION = "What should the first commit message be?";
|
||||
const LINKED_QUESTION = "Sign in at https://example.com/login, then follow the setup guide. Done?";
|
||||
|
||||
function selectedText(page: Page) {
|
||||
return page.evaluate(() => window.getSelection()?.toString().trim());
|
||||
}
|
||||
|
||||
const CHECKS_QUESTION = "Which checks should run?";
|
||||
const DONE_DESCRIPTION = "Logs are at https://example.com/logs if sign-in fails.";
|
||||
const LINT_DESCRIPTION = "Rules: https://example.com/lint";
|
||||
|
||||
async function openLinkedQuestions(page: Page, context: BrowserContext) {
|
||||
await context.route("https://example.com/**", (route) =>
|
||||
route.fulfill({ status: 200, body: "ok" }),
|
||||
);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "question-option-links-",
|
||||
title: "Question option links e2e",
|
||||
initialPrompt: "Emit synthetic questions with links.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
return { session, card: page.getByTestId("question-form-card").first() };
|
||||
}
|
||||
|
||||
async function dragAcross(page: Page, target: Locator) {
|
||||
const bounds = await target.boundingBox();
|
||||
if (!bounds) throw new Error("Text is not visible");
|
||||
const y = bounds.y + bounds.height / 2;
|
||||
await page.mouse.move(bounds.x + 1, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function clickLinkAndExpectPage(context: BrowserContext, link: Locator, url: string) {
|
||||
const popup = context.waitForEvent("page");
|
||||
await link.click();
|
||||
await expect.poll(async () => (await popup).url()).toBe(url);
|
||||
}
|
||||
|
||||
async function expectChecked(option: Locator, checked: boolean) {
|
||||
await expect(option).toHaveAttribute("aria-checked", String(checked));
|
||||
}
|
||||
|
||||
test.describe("Question option text", () => {
|
||||
test("links in an option label or description open without answering", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
const done = card.getByRole("radio", { name: "Done" });
|
||||
const notYet = card.getByRole("radio", { name: "Not yet" });
|
||||
|
||||
await clickLinkAndExpectPage(
|
||||
context,
|
||||
notYet.getByRole("link", { name: "status" }),
|
||||
"https://example.com/status",
|
||||
);
|
||||
await clickLinkAndExpectPage(
|
||||
context,
|
||||
done.getByRole("link", { name: "https://example.com/logs" }),
|
||||
"https://example.com/logs",
|
||||
);
|
||||
|
||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||
await expectChecked(done, false);
|
||||
await expectChecked(notYet, false);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting description text never answers, and the label still does", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
const done = card.getByRole("radio", { name: "Done" });
|
||||
const description = done.getByTestId("question-form-option-description");
|
||||
|
||||
await dragAcross(page, description);
|
||||
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
|
||||
await expectChecked(done, false);
|
||||
|
||||
await description.getByText("Logs are at", { exact: false }).dblclick({
|
||||
position: { x: 4, y: 4 },
|
||||
});
|
||||
await expect.poll(() => selectedText(page)).toBe("Logs");
|
||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||
await expectChecked(done, false);
|
||||
|
||||
await done.getByText("Done", { exact: true }).click();
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
await openQuestion(page, { index: 1, total: 2 });
|
||||
await expectChecked(done, true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("double-clicking a single-select label answers once", async ({ page, context }) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
|
||||
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
|
||||
await expectChecked(card.getByRole("checkbox", { name: "Typecheck" }), false);
|
||||
await openQuestion(page, { index: 1, total: 2 });
|
||||
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("multi-select description links and selection leave the option unchanged", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
|
||||
const lint = card.getByRole("checkbox", { name: "Lint" });
|
||||
const typecheck = card.getByRole("checkbox", { name: "Typecheck" });
|
||||
const description = lint.getByTestId("question-form-option-description");
|
||||
|
||||
await clickLinkAndExpectPage(
|
||||
context,
|
||||
description.getByRole("link", { name: "https://example.com/lint" }),
|
||||
"https://example.com/lint",
|
||||
);
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await dragAcross(page, description);
|
||||
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await description.getByText("Rules:", { exact: false }).dblclick({
|
||||
position: { x: 4, y: 4 },
|
||||
});
|
||||
await expect.poll(() => selectedText(page)).toBe("Rules");
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
|
||||
await expectChecked(typecheck, true);
|
||||
await lint.getByText("Lint", { exact: true }).click();
|
||||
await expectChecked(lint, true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Question prompt pagination", () => {
|
||||
test("question links open without answering, and the question text is selectable", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await context.route("https://example.com/**", (route) =>
|
||||
route.fulfill({ status: 200, body: "ok" }),
|
||||
);
|
||||
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "question-links-",
|
||||
title: "Question links e2e",
|
||||
initialPrompt: "Emit synthetic questions with links.",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
const question = card.getByTestId("question-form-current-question");
|
||||
await expect(question).toHaveText(LINKED_QUESTION);
|
||||
await expect(question.getByRole("link", { name: "https://example.com/login" })).toBeVisible();
|
||||
await expect(question.getByRole("link", { name: "the setup guide" })).toBeVisible();
|
||||
|
||||
const bounds = await question.boundingBox();
|
||||
if (!bounds) throw new Error("Question text is not visible");
|
||||
const y = bounds.y + bounds.height / 2;
|
||||
await page.mouse.move(bounds.x + 1, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => selectedText(page)).toBe(LINKED_QUESTION);
|
||||
|
||||
const popup = context.waitForEvent("page");
|
||||
await question.getByRole("link", { name: "the setup guide" }).click();
|
||||
await expect.poll(async () => (await popup).url()).toBe("https://example.com/setup");
|
||||
|
||||
await expect(page.getByTestId("question-form-card")).toHaveCount(1);
|
||||
for (const option of ["Done", "Not yet"]) {
|
||||
await expect(card.getByRole("radio", { name: option })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows one question at a time with numbered navigation", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { readFile, mkdir, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
@@ -25,6 +29,8 @@ import {
|
||||
removeHostFromHostPage,
|
||||
addDirectHostFromSettings,
|
||||
expectHostOnlineWithoutError,
|
||||
expectIdleAgentRelease,
|
||||
setIdleAgentRelease,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
@@ -137,6 +143,100 @@ test.describe("Settings host page", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("idle agent release changes live on one host and stays saved without restarting", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const primaryServerId = getServerId();
|
||||
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_agent_release");
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "idle-release-settings",
|
||||
port: daemon.port,
|
||||
});
|
||||
const originalPid = (await client.getDaemonStatus()).pid;
|
||||
const expectLiveSavedSetting = async (enabled: boolean) => {
|
||||
const saved = JSON.parse(await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"));
|
||||
expect(saved.agents.idleRuntimeCleanup).toBe(enabled);
|
||||
expect((await client.getDaemonConfig()).config.idleRuntimeCleanup).toBe(enabled);
|
||||
expect((await client.reloadDaemonConfig()).restartRequiredPaths).toEqual([]);
|
||||
expect((await client.getDaemonStatus()).pid).toBe(originalPid);
|
||||
};
|
||||
const hosts = [
|
||||
{
|
||||
serverId: primaryServerId,
|
||||
label: TEST_HOST_LABEL,
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: daemon.serverId,
|
||||
label: "Idle release host",
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
},
|
||||
];
|
||||
const openHostAgentsInFreshApp = async (serverId: string) => {
|
||||
await seedSavedSettingsHosts(page, hosts);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "agents");
|
||||
};
|
||||
try {
|
||||
await test.step("idle agent release starts on and turns off", async () => {
|
||||
await openHostAgentsInFreshApp(daemon.serverId);
|
||||
await expectIdleAgentRelease(page, true);
|
||||
await setIdleAgentRelease(page, false);
|
||||
await expectLiveSavedSetting(false);
|
||||
});
|
||||
|
||||
await test.step("a fresh app reads the saved setting from the same running host", async () => {
|
||||
await openHostAgentsInFreshApp(daemon.serverId);
|
||||
await expectIdleAgentRelease(page, false);
|
||||
await expectLiveSavedSetting(false);
|
||||
await setIdleAgentRelease(page, true);
|
||||
await expectLiveSavedSetting(true);
|
||||
await setIdleAgentRelease(page, false);
|
||||
});
|
||||
|
||||
await test.step("other hosts keep their own setting", async () => {
|
||||
await selectSettingsHost(page, primaryServerId);
|
||||
await openHostSection(page, primaryServerId, "agents");
|
||||
await expectIdleAgentRelease(page, true);
|
||||
await expectLiveSavedSetting(false);
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed idle release save stays visible and retry applies it live", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_release_failure");
|
||||
const configPath = path.join(daemon.paseoHome, "config.json");
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{ serverId: daemon.serverId, label: "Failure host", endpoint: `127.0.0.1:${daemon.port}` },
|
||||
]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openHostSection(page, daemon.serverId, "agents");
|
||||
await expectIdleAgentRelease(page, true);
|
||||
// A real filesystem failure, with the daemon and RPC path intact.
|
||||
await rename(configPath, `${configPath}.saved`);
|
||||
await mkdir(configPath);
|
||||
await page.getByRole("switch", { name: "Release idle agents" }).click();
|
||||
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toContainText(
|
||||
"Failed to read",
|
||||
);
|
||||
await expectIdleAgentRelease(page, true);
|
||||
await rm(configPath, { recursive: true });
|
||||
await rename(`${configPath}.saved`, configPath);
|
||||
await setIdleAgentRelease(page, false);
|
||||
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toHaveCount(0);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
addDirectHostFromSettings,
|
||||
clickSettingsBackToWorkspace,
|
||||
openSettingsHostSection,
|
||||
openSettingsSection,
|
||||
selectSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
|
||||
@@ -44,6 +47,14 @@ test.describe("Settings host selection", () => {
|
||||
await openSettings(page);
|
||||
|
||||
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
|
||||
|
||||
await selectSettingsHost(page, getServerId());
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsHostSection(page, getServerId(), "connections");
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
|
||||
} finally {
|
||||
await remoteWorkspace.cleanup().catch(() => undefined);
|
||||
await remoteDaemon.close().catch(() => undefined);
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
} from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function openWorkspace(
|
||||
page: import("@playwright/test").Page,
|
||||
@@ -53,6 +55,74 @@ async function openWorkspace(
|
||||
}
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("opening Settings closes the composer model selector until it is opened again", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
|
||||
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
|
||||
const popover = page.getByTestId("combobox-desktop-container");
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+,`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(selector).toBeVisible();
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popover).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("opening Settings closes an existing chat's model selector until it is opened again", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-existing-model-",
|
||||
title: "Settings model selector",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
|
||||
const popover = page.getByTestId("combobox-desktop-container");
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+,`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("settings-after-existing-model-selector.png"),
|
||||
});
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(selector).toBeVisible();
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popover).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
@@ -77,6 +147,28 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("host choice survives app sections and host screen replacements", async ({ page }) => {
|
||||
const primaryServerId = getServerId();
|
||||
const secondaryServerId = "srv_e2e_settings_secondary";
|
||||
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{ serverId: primaryServerId, label: "First horse", endpoint },
|
||||
{ serverId: secondaryServerId, label: "Stable horse", endpoint },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, secondaryServerId);
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectSettingsHostPickerLabel(page, "Stable horse");
|
||||
await openSettingsSection(page, "general");
|
||||
await expectSettingsHostPickerLabel(page, "Stable horse");
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await selectSettingsHost(page, primaryServerId);
|
||||
await openSettingsSection(page, "about");
|
||||
await expectSettingsHostPickerLabel(page, "First horse");
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
@@ -147,6 +239,42 @@ test.describe("Settings sidebar navigation", () => {
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-tab-switcher-",
|
||||
title: "Compact tab switcher",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
|
||||
const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
|
||||
await switcher.click();
|
||||
await expect(newAgent).toBeInViewport();
|
||||
|
||||
// Navigate without dismissing the sheet through its backdrop first.
|
||||
await page
|
||||
.getByTestId("menu-button")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.dispatchEvent("click");
|
||||
await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
|
||||
await closeCompactSettings(page);
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
await switcher.click();
|
||||
await expect(newAgent).toBeInViewport();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens compact app and host details and returns through the settings list", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -223,6 +351,15 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
await openSettingsSection(page, "appearance");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
});
|
||||
|
||||
test("removing the last active host returns to welcome after settings closes", async ({
|
||||
|
||||
@@ -10,6 +10,16 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
}
|
||||
|
||||
async function openComposerTab(page: import("@playwright/test").Page, agentId: string) {
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${agentId}`)
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
await expectAgentTabActive(page, agentId);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
}
|
||||
|
||||
interface ComposerFrame {
|
||||
at: number;
|
||||
visible: boolean;
|
||||
@@ -27,7 +37,9 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page)
|
||||
|
||||
const sample = () => {
|
||||
if (capture.stopped) return;
|
||||
const root = document.querySelector<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");
|
||||
if (root && input) {
|
||||
const style = getComputedStyle(input);
|
||||
@@ -178,6 +190,8 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
await waitForTabBar(page);
|
||||
|
||||
await openComposerTab(page, firstAgent.id);
|
||||
await openComposerTab(page, secondAgent.id);
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await input.fill("Keep this short draft in the composer");
|
||||
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||
@@ -194,6 +208,7 @@ test.describe("Settings toggle tab regression", () => {
|
||||
|
||||
const frames = await stopComposerFrameCapture(page);
|
||||
const visibleFrames = frames.filter((frame) => frame.visible);
|
||||
expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0);
|
||||
const tallestIndex = visibleFrames.reduce(
|
||||
(index, frame, candidateIndex) =>
|
||||
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
claudeAndCodexReports,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
expectCompactSidebarOpen,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
|
||||
@@ -90,12 +91,13 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy
|
||||
await openAddProjectFlow(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProjectFlow(page)).toBeHidden();
|
||||
await expectCompactSidebarOpen(page);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await openSidebarNavSettings(page);
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await leaveSettings(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openCompactSidebar(page);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await expectFooterSeparator(page, true);
|
||||
await expectFooterIconRow(page);
|
||||
await footerScreenshot(page, "footer-compact-with-rows");
|
||||
|
||||
@@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi
|
||||
}
|
||||
});
|
||||
|
||||
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
|
||||
async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Sort/ }).click();
|
||||
await page.getByRole("menuitem", { name, exact: true }).click();
|
||||
@@ -362,3 +362,60 @@ test("compact groups retain active overflow and reveal the current ordered rows"
|
||||
await fixture.seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) {
|
||||
await page.evaluate(
|
||||
({ projectKey: scope, workspaceKeys: order }) => {
|
||||
localStorage.setItem(
|
||||
"sidebar-project-workspace-order",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
state: {
|
||||
projectOrder: [scope],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: { [scope]: order },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ projectKey, workspaceKeys },
|
||||
);
|
||||
await reconnectSidebar(page);
|
||||
}
|
||||
|
||||
test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" });
|
||||
try {
|
||||
const second = await seed.client.createWorkspace({
|
||||
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
|
||||
title: "New manual workspace",
|
||||
});
|
||||
if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed");
|
||||
const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`);
|
||||
const ids = keys.map((key) => `sidebar-workspace-row-${key}`);
|
||||
const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", "));
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
|
||||
await selectWorkspaceSort(page, "Last prompt");
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
|
||||
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
|
||||
await reconnectSidebar(page);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
|
||||
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
await reconnectSidebar(page);
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
|
||||
} finally {
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -72,6 +72,9 @@ async function expectSortingRequiresHostUpdate(page: Page) {
|
||||
).toBeDisabled();
|
||||
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
|
||||
await page.getByRole("menuitem", { name: "Manual", exact: true }).click();
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual");
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ async function addFinishedAgent(workspace: MockAgentWorkspace) {
|
||||
async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentRoute(page, workspace);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await openMobileAgentSidebar(page);
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,29 @@ test("opening an already-tabbed subagent reveals its left tab without a duplicat
|
||||
}
|
||||
});
|
||||
|
||||
test("a subagent's parent pill returns to its parent", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-parent-pill-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Refactor auth flow",
|
||||
childTitle: "Write session tests",
|
||||
});
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId: pair.workspaceId, agentId: pair.child.id });
|
||||
|
||||
const pill = page.getByTestId("composer-parent-agent-pill");
|
||||
await expect(pill).toHaveAccessibleName("Back to Refactor auth flow");
|
||||
await pill.click();
|
||||
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${pair.parent.id}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||
const target = "linked.ts:3";
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -143,7 +143,7 @@ async function configureOverviewToolCalls(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ test("measures an overview heading that becomes loading after its idle mount", a
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
|
||||
|
||||
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
|
||||
}, chatDisplay);
|
||||
}
|
||||
|
||||
async function seedAnsweredAgent(title: string) {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "turn-collapse-",
|
||||
title,
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Why does the chat stop following new output?",
|
||||
});
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
return agent;
|
||||
}
|
||||
|
||||
/** Chat rows between the latest turn's summary line and its reply, in document order. */
|
||||
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
|
||||
const replyRow = rows.findLastIndex((row) =>
|
||||
row.querySelector("[data-testid=assistant-message]"),
|
||||
);
|
||||
return replyRow - summaryRow - 1;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Turn collapse", () => {
|
||||
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedAnsweredAgent("Turn collapse");
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
await expect(summary).toHaveText(/^Wrote \d+ messages?, edited 1 file/);
|
||||
// Text chunks that stream back to back merge into one message, so the reply holds
|
||||
// at least the stream's last chunk.
|
||||
await expect(page.getByTestId("assistant-message").last()).toHaveText(
|
||||
/\(end of synthetic stream\)$/,
|
||||
);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
|
||||
await expect(summary).toBeInViewport();
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,13 @@ MESSAGE=$'keyboard invariant line one\nline two\nline three\nline four'
|
||||
LONG_MESSAGE="$(node -e 'process.stdout.write(Array.from({ length: 180 }, (_, index) => `line${index + 1}`).join(" "))')"
|
||||
BLANK_LINE_DRAFT="$(node -e 'process.stdout.write("\n".repeat(22) + "ddjdj")')"
|
||||
AGENT_TITLE="Keyboard dismiss QA $(date +%s)"
|
||||
# `overlays` runs only each host's autocomplete, model selector, attachment menu, and
|
||||
# forge picker checks; `all` also runs the chat baseline and the full host scenario.
|
||||
SCENARIO="${PASEO_COMPOSER_KEYBOARD_SCENARIO:-all}"
|
||||
if [[ "${SCENARIO}" != all && "${SCENARIO}" != overlays ]]; then
|
||||
echo "PASEO_COMPOSER_KEYBOARD_SCENARIO must be all or overlays" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${SERVER_ID}" ]]; then
|
||||
if [[ ! -f "${DAEMON_HOME}/server-id" ]]; then
|
||||
@@ -173,8 +180,10 @@ sleep 3
|
||||
|
||||
# Create the fixture while the client is connected so its directory replica observes the insert.
|
||||
ad wait 'id="message-input-root"' 90000
|
||||
# The compact header names the workspace, not the agent, so the chat waits below find
|
||||
# the fixture by its prompt bubble.
|
||||
run_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
|
||||
npm run --silent cli -- run "Exercise the composer keyboard invariant flow" \
|
||||
npm run --silent cli -- run "${AGENT_TITLE}" \
|
||||
--background \
|
||||
--title "${AGENT_TITLE}" \
|
||||
--provider mock \
|
||||
@@ -206,238 +215,241 @@ clear_input
|
||||
ad keyboard dismiss || true
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
|
||||
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
|
||||
capture_screen "${ARTIFACTS_DIR}/baseline.png"
|
||||
read -r input_x input_y baseline_input_height < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
|
||||
)
|
||||
read -r model_x model_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
|
||||
)
|
||||
read -r attach_x attach_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
|
||||
)
|
||||
read -r _ header_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
|
||||
)
|
||||
# Chat-only invariants, then the long-draft checks on New workspace.
|
||||
run_chat_baseline() {
|
||||
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
|
||||
capture_screen "${ARTIFACTS_DIR}/baseline.png"
|
||||
read -r input_x input_y baseline_input_height < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
|
||||
)
|
||||
read -r model_x model_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
|
||||
)
|
||||
read -r attach_x attach_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
|
||||
)
|
||||
read -r _ header_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
|
||||
)
|
||||
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input keyevent 76
|
||||
sleep 1
|
||||
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
ad wait 'text="/exit"' 10000
|
||||
model_open_y="$((model_y - keyboard_shift))"
|
||||
adb shell input tap "${model_x}" "${model_open_y}"
|
||||
sleep 1
|
||||
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="compact-provider-list"' 10000
|
||||
ad press 'label="Close"' --settle
|
||||
adb shell input tap "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
ad wait 'editable=true' 10000
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input keyevent 76
|
||||
sleep 1
|
||||
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
ad wait 'text="/exit"' 10000
|
||||
model_open_y="$((model_y - keyboard_shift))"
|
||||
adb shell input tap "${model_x}" "${model_open_y}"
|
||||
sleep 1
|
||||
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="compact-provider-list"' 10000
|
||||
ad press 'label="Close"' --settle
|
||||
adb shell input tap "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
ad wait 'editable=true' 10000
|
||||
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
adb shell input keyevent 76
|
||||
sleep 1
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
ad wait 'text="/exit"' 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
|
||||
node "${ASSERT}" above-y \
|
||||
"${ARTIFACTS_DIR}/command-popover.json" \
|
||||
"/exit, Archive the current agent" \
|
||||
"$((input_y - baseline_input_height / 2))"
|
||||
adb shell input keyevent 67
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
adb shell input keyevent 76
|
||||
sleep 1
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
ad wait 'text="/exit"' 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
|
||||
node "${ASSERT}" above-y \
|
||||
"${ARTIFACTS_DIR}/command-popover.json" \
|
||||
"/exit, Archive the current agent" \
|
||||
"$((input_y - baseline_input_height / 2))"
|
||||
adb shell input keyevent 67
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
|
||||
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
|
||||
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/multiline-first.json" \
|
||||
"${ARTIFACTS_DIR}/multiline-second.json"
|
||||
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
|
||||
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/multiline-first.json" \
|
||||
"${ARTIFACTS_DIR}/multiline-second.json"
|
||||
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
|
||||
|
||||
node "${ASSERT}" same-header \
|
||||
"${ARTIFACTS_DIR}/baseline.png" \
|
||||
"${ARTIFACTS_DIR}/keyboard-open.png" \
|
||||
"$((header_y + 48))"
|
||||
node "${ASSERT}" same-header \
|
||||
"${ARTIFACTS_DIR}/baseline.png" \
|
||||
"${ARTIFACTS_DIR}/keyboard-open.png" \
|
||||
"$((header_y + 48))"
|
||||
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-above-y \
|
||||
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
|
||||
"Add attachment" \
|
||||
"$(read_ime_top)"
|
||||
display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')"
|
||||
node "${ASSERT}" xml-composer-contained \
|
||||
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
|
||||
"$(read_ime_top)" \
|
||||
"${display_density}"
|
||||
# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to
|
||||
# its baseline height. Fabric only re-measures the Android input when its text prop changes,
|
||||
# so the editing input renders again after every edit
|
||||
# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the
|
||||
# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the
|
||||
# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from
|
||||
# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key
|
||||
# row on the paseo-api35 layout.
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keycombination 113 122
|
||||
adb shell input keyevent 20 20 20 20 20 20 20 20
|
||||
screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)"
|
||||
backspace_x="$((screen_width - 85))"
|
||||
backspace_y="$(($(read_ime_top) + 507))"
|
||||
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000
|
||||
adb shell input keycombination 113 123
|
||||
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/baseline.json" \
|
||||
"${ARTIFACTS_DIR}/after-hold-delete.json"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-above-y \
|
||||
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
|
||||
"Add attachment" \
|
||||
"$(read_ime_top)"
|
||||
display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')"
|
||||
node "${ASSERT}" xml-composer-contained \
|
||||
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
|
||||
"$(read_ime_top)" \
|
||||
"${display_density}"
|
||||
# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to
|
||||
# its baseline height. Fabric only re-measures the Android input when its text prop changes,
|
||||
# so the editing input renders again after every edit
|
||||
# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the
|
||||
# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the
|
||||
# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from
|
||||
# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key
|
||||
# row on the paseo-api35 layout.
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keycombination 113 122
|
||||
adb shell input keyevent 20 20 20 20 20 20 20 20
|
||||
screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)"
|
||||
backspace_x="$((screen_width - 85))"
|
||||
backspace_y="$(($(read_ime_top) + 507))"
|
||||
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000
|
||||
adb shell input keycombination 113 123
|
||||
adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/baseline.json" \
|
||||
"${ARTIFACTS_DIR}/after-hold-delete.json"
|
||||
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${MESSAGE}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${MESSAGE}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
|
||||
read -r changes_x changes_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
|
||||
)
|
||||
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
|
||||
ad wait 'id="changes-header"' 10000
|
||||
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
|
||||
ad press 'label="Close Explorer sidebar"' --settle
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||
ad wait 'editable=true' 10000
|
||||
read -r changes_x changes_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
|
||||
)
|
||||
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
|
||||
ad wait 'id="changes-header"' 10000
|
||||
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
|
||||
ad press 'label="Close Explorer sidebar"' --settle
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||
ad wait 'editable=true' 10000
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
|
||||
sleep 1
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="message-input-attachment-menu-content"' 10000
|
||||
ad press 540 600 --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
|
||||
sleep 1
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="message-input-attachment-menu-content"' 10000
|
||||
ad press 540 600 --settle
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap 983 "$((attach_y - keyboard_shift))"
|
||||
wait_for_ime false
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait text "keyboard invariant line one" 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
|
||||
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/baseline.json" \
|
||||
"${ARTIFACTS_DIR}/after-submit.json"
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap 983 "$((attach_y - keyboard_shift))"
|
||||
wait_for_ime false
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait text "keyboard invariant line one" 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
|
||||
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/baseline.json" \
|
||||
"${ARTIFACTS_DIR}/after-submit.json"
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
|
||||
stall_pid="$!"
|
||||
sleep 0.25
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
|
||||
wait "${stall_pid}"
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
|
||||
node "${ASSERT}" same-region \
|
||||
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
|
||||
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
|
||||
1400 \
|
||||
430
|
||||
node "${ASSERT}" same-region \
|
||||
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
|
||||
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
|
||||
1400 \
|
||||
430
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
|
||||
stall_pid="$!"
|
||||
sleep 0.25
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
|
||||
wait "${stall_pid}"
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
|
||||
node "${ASSERT}" same-region \
|
||||
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
|
||||
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
|
||||
1400 \
|
||||
430
|
||||
node "${ASSERT}" same-region \
|
||||
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
|
||||
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
|
||||
1400 \
|
||||
430
|
||||
|
||||
ad press 'id="menu-button"' --settle
|
||||
ad press 'id="sidebar-global-new-workspace"' --settle
|
||||
ad wait 'id="workspace-create-submit"' 10000
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "x" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json"
|
||||
read -r new_input_x new_input_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable"
|
||||
)
|
||||
open_gboard "${new_input_x}" "${new_input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml"
|
||||
# Typing more lines moves the setup fields up with the composer instead of
|
||||
# hiding their bottom rows behind it.
|
||||
adb shell input keycombination 113 123
|
||||
adb shell input keyevent 66 66 66
|
||||
adb shell input text grown
|
||||
sleep 1
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-fields-follow-growth \
|
||||
"${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \
|
||||
"${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
|
||||
ad press 'id="menu-button"' --settle
|
||||
ad press 'id="sidebar-global-new-workspace"' --settle
|
||||
ad wait 'id="workspace-create-submit"' 10000
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "x" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json"
|
||||
read -r new_input_x new_input_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable"
|
||||
)
|
||||
open_gboard "${new_input_x}" "${new_input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml"
|
||||
# Typing more lines moves the setup fields up with the composer instead of
|
||||
# hiding their bottom rows behind it.
|
||||
adb shell input keycombination 113 123
|
||||
adb shell input keyevent 66 66 66
|
||||
adb shell input text grown
|
||||
sleep 1
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-fields-follow-growth \
|
||||
"${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \
|
||||
"${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
|
||||
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
|
||||
read -r new_input_x new_input_y new_input_height < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable"
|
||||
)
|
||||
# The editor shrinks when the keyboard opens. Its bottom stays anchored, while
|
||||
# its old center can move into the setup fields after the height constraint.
|
||||
new_input_y="$((new_input_y + new_input_height / 2 - 8))"
|
||||
open_gboard "${new_input_x}" "${new_input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-composer-contained \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
|
||||
"$(read_ime_top)" \
|
||||
"${display_density}"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
|
||||
read -r new_input_x new_input_y new_input_height < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable"
|
||||
)
|
||||
# The editor shrinks when the keyboard opens. Its bottom stays anchored, while
|
||||
# its old center can move into the setup fields after the height constraint.
|
||||
new_input_y="$((new_input_y + new_input_height / 2 - 8))"
|
||||
open_gboard "${new_input_x}" "${new_input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-composer-contained \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
|
||||
"$(read_ime_top)" \
|
||||
"${display_density}"
|
||||
|
||||
# Dismissing the keyboard moves the composer down at its editing height and
|
||||
# brings the setup fields back fully visible above it.
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
node "${ASSERT}" xml-fields-above-composer \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
# Dismissing the keyboard moves the composer down at its editing height and
|
||||
# brings the setup fields back fully visible above it.
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
node "${ASSERT}" xml-fields-above-composer \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
}
|
||||
|
||||
# All hosts run the same scenario; only navigation differs.
|
||||
# For the shared scenario, tap the editor's blank right edge once. Tapping
|
||||
@@ -470,20 +482,80 @@ press_composer_control() {
|
||||
adb shell input tap "${x}" "${y}"
|
||||
}
|
||||
|
||||
# Opens suggestions with the keyboard closed, before it opens, or after it settled;
|
||||
# checks their bounds and taps the best match where it is drawn. Accessibility keeps
|
||||
# reporting rows behind the keyboard, so existence is not evidence. It also drops the
|
||||
# composer while suggestions are open, so the composer bounds come from a reference
|
||||
# snapshot with an empty input in the keyboard state the suggestions are checked in.
|
||||
check_autocomplete() {
|
||||
local host="$1" order="$2" kind="$3" query="$4" expected="$5" inserted="$6"
|
||||
local prefix="${ARTIFACTS_DIR}/${host}-${kind}-${order}"
|
||||
if [[ "${order}" == keyboard-closed ]]; then
|
||||
prepare_composer closed
|
||||
snapshot_json "${prefix}-reference.json"
|
||||
adb shell input text "${query}"
|
||||
else
|
||||
prepare_composer open
|
||||
# The composer follows the keyboard animation, which ends after the IME frame settles.
|
||||
sleep 1
|
||||
snapshot_json "${prefix}-reference.json"
|
||||
if [[ "${order}" == before-keyboard ]]; then
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
wait_for_ime false
|
||||
adb shell input text "${query}"
|
||||
open_editor_keyboard "${overlay_x}" "${overlay_y}"
|
||||
else
|
||||
adb shell input text "${query}"
|
||||
fi
|
||||
fi
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
# The popover remeasures once keyboard motion settles.
|
||||
sleep 1
|
||||
capture_screen "${prefix}.png"
|
||||
snapshot_json "${prefix}.json"
|
||||
local ime_top=""
|
||||
if [[ "${order}" != keyboard-closed ]]; then ime_top="$(read_ime_top)"; fi
|
||||
local best_x best_y
|
||||
read -r best_x best_y < <(
|
||||
node "${ASSERT}" autocomplete-best "${prefix}.json" "${prefix}-reference.json" "${expected}" "${ime_top}"
|
||||
)
|
||||
adb shell input tap "${best_x}" "${best_y}"
|
||||
sleep 1
|
||||
snapshot_json "${prefix}-selected.json"
|
||||
node "${ASSERT}" composer-text "${prefix}-selected.json" "${inserted}"
|
||||
clear_focused_input_with_adb
|
||||
echo "PASS host=${host}: ${kind} ${order}, ${expected} drawn above composer and keyboard and inserted"
|
||||
}
|
||||
|
||||
# Drafts list commands for their selected provider, and a fresh install selects none.
|
||||
select_claude_model() {
|
||||
local x y h
|
||||
press_composer_control combined-model-selector
|
||||
ad wait 'id="agent-controls-model"' 10000
|
||||
ad press 'id="agent-controls-model"' --settle
|
||||
ad press 'id="model-provider-claude"' --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/model-list.json"
|
||||
read -r x y h < <(node "${ASSERT}" rect "${ARTIFACTS_DIR}/model-list.json" 'model-row-claude-*')
|
||||
adb shell input tap "${x}" "${y}"
|
||||
ad wait 'id="agent-controls-thinking"' 10000
|
||||
ad press 'label="Close"' --settle
|
||||
}
|
||||
|
||||
run_host_overlays() {
|
||||
local host="$1"
|
||||
if [[ "${host}" != chat ]]; then
|
||||
prepare_composer closed
|
||||
select_claude_model
|
||||
fi
|
||||
# Chat lists the mock provider's command; drafts list the release skills in
|
||||
# this repository's .claude/skills for the selected Claude model.
|
||||
local command_query=/release command_match=/release-beta command_inserted="/release-beta "
|
||||
if [[ "${host}" == chat ]]; then command_query=/mo command_match=/mock command_inserted="/mock "; fi
|
||||
for order in keyboard-closed before-keyboard after-keyboard; do
|
||||
check_autocomplete "${host}" "${order}" commands "${command_query}" "${command_match}" "${command_inserted}"
|
||||
check_autocomplete "${host}" "${order}" files @app packages/app '"packages/app"'
|
||||
done
|
||||
for mode in closed open; do
|
||||
for query in / @README; do
|
||||
prepare_composer closed
|
||||
adb shell input text "${query}"
|
||||
if [[ "${mode}" == open ]]; then open_editor_keyboard "${overlay_x}" "${overlay_y}"; fi
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
local kind=commands
|
||||
if [[ "${query}" == @* ]]; then kind=files; fi
|
||||
capture_screen "${ARTIFACTS_DIR}/${host}-${kind}-${mode}.png"
|
||||
clear_focused_input_with_adb
|
||||
echo "PASS host=${host}: ${kind} popover keyboard=${mode}"
|
||||
done
|
||||
prepare_composer "${mode}"
|
||||
press_composer_control combined-model-selector
|
||||
ad wait 'id="compact-provider-list"' 10000
|
||||
@@ -656,19 +728,29 @@ run_host_scenario() {
|
||||
run_host_scroll "${host}"
|
||||
}
|
||||
|
||||
run_host() {
|
||||
if [[ "${SCENARIO}" == overlays ]]; then
|
||||
run_host_overlays "$1"
|
||||
else
|
||||
run_host_scenario "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${SCENARIO}" == all ]]; then run_chat_baseline; fi
|
||||
adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agent/${agent_id}" "${APP_ID}" >/dev/null
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||
run_host_scenario chat
|
||||
run_host chat
|
||||
ad keyboard dismiss || true
|
||||
ad press 'id="workspace-tab-switcher-trigger"' --settle
|
||||
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
|
||||
ad press 'id="workspace-tab-switcher-new-agent"' --settle
|
||||
ad wait 'text="New Agent"' 10000
|
||||
run_host_scenario workspace-draft
|
||||
# Only a draft offers to import a session.
|
||||
ad wait 'id="composer-import-agent-pill"' 10000
|
||||
run_host workspace-draft
|
||||
ad keyboard dismiss || true
|
||||
ad press 'id="menu-button"' --settle
|
||||
ad press 'id="sidebar-global-new-workspace"' --settle
|
||||
ad wait 'id="workspace-create-submit"' 10000
|
||||
run_host_scenario new-workspace
|
||||
run_host new-workspace
|
||||
|
||||
echo "Composer keyboard invariants passed"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import sharp from "sharp";
|
||||
import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs";
|
||||
|
||||
const [, , command, ...args] = process.argv;
|
||||
|
||||
@@ -115,11 +116,14 @@ if (command === "stream-moved") {
|
||||
`Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`,
|
||||
);
|
||||
} else if (command === "rect") {
|
||||
// A trailing `*` matches the first identifier with that prefix.
|
||||
const [snapshotPath, identifier, edge] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const prefix = identifier.endsWith("*") ? identifier.slice(0, -1) : null;
|
||||
const node = snapshot.data.nodes.find(
|
||||
(candidate) =>
|
||||
candidate.identifier === identifier ||
|
||||
(prefix !== null && candidate.identifier?.startsWith(prefix)) ||
|
||||
candidate.label === identifier ||
|
||||
(identifier === "editable" && candidate.type.endsWith("EditText")),
|
||||
);
|
||||
@@ -232,6 +236,27 @@ if (command === "stream-moved") {
|
||||
if (!snapshot.data.nodes.some((candidate) => candidate.label === expected)) {
|
||||
throw new Error(`Missing exact text in ${snapshotPath}: ${expected}`);
|
||||
}
|
||||
} else if (command === "autocomplete-best") {
|
||||
// Prints the center of the expected best match after checking its placement
|
||||
// against the composer in the reference snapshot.
|
||||
const [snapshotPath, referencePath, expected, imeTopArgument] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const reference = JSON.parse(await fs.readFile(referencePath, "utf8"));
|
||||
const best = resolveAutocompletePlacement(snapshot, {
|
||||
expected,
|
||||
keyboardTop: imeTopArgument ? Number(imeTopArgument) : undefined,
|
||||
reference,
|
||||
});
|
||||
process.stdout.write(`${best.x} ${best.y}\n`);
|
||||
} else if (command === "composer-text") {
|
||||
// Accessibility drops a trailing space, so compare without it.
|
||||
const [snapshotPath, expected] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText"));
|
||||
const text = input?.value ?? input?.label ?? "";
|
||||
if (text.trimEnd() !== expected.trimEnd()) {
|
||||
throw new Error(`Composer text ${JSON.stringify(text)}, expected ${JSON.stringify(expected)}`);
|
||||
}
|
||||
} else if (command === "same-region") {
|
||||
const [firstPath, secondPath, topArgument, heightArgument, failureMessage] = args;
|
||||
const crop = {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Shared by the iOS and Android composer keyboard harnesses. Reads an agent-device
|
||||
// `snapshot --raw --json` result. Accessibility keeps reporting suggestion rows that
|
||||
// sit behind the keyboard, so existence is not evidence: compare their screen bounds.
|
||||
|
||||
const bottomOf = (rect) => rect.y + rect.height;
|
||||
|
||||
function findById(nodes, identifier) {
|
||||
const node = nodes.find((candidate) => candidate.identifier === identifier && candidate.rect);
|
||||
if (!node) throw new Error(`Missing node: ${identifier}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
function contains(outer, inner) {
|
||||
return (
|
||||
inner.x >= outer.x - 1 &&
|
||||
inner.y >= outer.y - 1 &&
|
||||
inner.x + inner.width <= outer.x + outer.width + 1 &&
|
||||
bottomOf(inner) <= bottomOf(outer) + 1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The best match is the list row nearest the composer. Check that the popover sits
|
||||
* between the app's header controls and the composer, that the keyboard (when shown)
|
||||
* is below both, and that the row nearest the composer is fully drawn and is
|
||||
* `expected`. A clipped best match fails: the nearest whole row is then another one.
|
||||
* Return where to tap it. Android leaves the composer out of the accessibility tree
|
||||
* while suggestions are open, so its callers read the composer from `reference`: a
|
||||
* snapshot in the same keyboard state with an empty input.
|
||||
*/
|
||||
export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, reference }) {
|
||||
if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot");
|
||||
const nodes = snapshot.data.nodes;
|
||||
const popover = findById(nodes, "composer-autocomplete-popover").rect;
|
||||
const composer = findById((reference ?? snapshot).data.nodes, "message-input-root").rect;
|
||||
// The popover may cover the compact header, never the status bar above it.
|
||||
const minimumTop =
|
||||
nodes.find((node) => node.identifier === "menu-button" && node.rect)?.rect.y ?? 0;
|
||||
// iOS reports the keyboard in the app's tree; Android callers pass the IME frame.
|
||||
const keyboardY =
|
||||
keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y;
|
||||
const popoverBottom = bottomOf(popover);
|
||||
if (popoverBottom > composer.y + 1) {
|
||||
throw new Error(
|
||||
`Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`,
|
||||
);
|
||||
}
|
||||
if (popover.y < minimumTop - 1) {
|
||||
throw new Error(
|
||||
`Autocomplete extends above the header: top=${popover.y}, minimum=${minimumTop}`,
|
||||
);
|
||||
}
|
||||
if (keyboardY !== undefined && popoverBottom > keyboardY + 1) {
|
||||
throw new Error(
|
||||
`Autocomplete sits behind the keyboard: popover bottom=${popoverBottom}, keyboard top=${keyboardY}`,
|
||||
);
|
||||
}
|
||||
// Rows are the innermost labelled boxes spanning the popover. The list scrolls to
|
||||
// its end, so the lowest row is the best match nearest the composer.
|
||||
const candidates = nodes.filter(
|
||||
(node) =>
|
||||
node.rect &&
|
||||
node.label &&
|
||||
node.identifier !== "composer-autocomplete-popover" &&
|
||||
!/scroll bar/i.test(node.label) &&
|
||||
node.rect.height > 0 &&
|
||||
// The popover border insets rows by a few pixels; their labels are narrower.
|
||||
node.rect.width >= popover.width * 0.97 &&
|
||||
contains(popover, node.rect),
|
||||
);
|
||||
const area = (rect) => rect.width * rect.height;
|
||||
const rows = candidates.filter(
|
||||
(outer) =>
|
||||
!candidates.some(
|
||||
(inner) => area(inner.rect) < area(outer.rect) - 1 && contains(outer.rect, inner.rect),
|
||||
),
|
||||
);
|
||||
if (rows.length === 0) throw new Error("Autocomplete has no visible rows");
|
||||
const best = rows.reduce((lowest, row) =>
|
||||
bottomOf(row.rect) > bottomOf(lowest.rect) ? row : lowest,
|
||||
);
|
||||
const label = best.label.split(", ")[0];
|
||||
if (label !== expected) {
|
||||
throw new Error(`Best visible match is ${label}, expected ${expected}`);
|
||||
}
|
||||
return {
|
||||
label,
|
||||
x: Math.round(best.rect.x + best.rect.width / 2),
|
||||
y: Math.round(best.rect.y + best.rect.height / 2),
|
||||
popover,
|
||||
composer,
|
||||
keyboardTop: keyboardY ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Composer autocomplete placement on iOS with the software keyboard: chat, workspace
|
||||
// draft tab, and New workspace. Run where agent-device reaches the simulator, with the
|
||||
// development app connected to the fixture host. See docs/mobile-testing.md.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../..");
|
||||
const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?? fallback;
|
||||
const serverId = env("SERVER_ID");
|
||||
const agentId = env("AGENT_ID");
|
||||
const workspaceId = env("WORKSPACE_ID");
|
||||
if (!serverId || !agentId || !workspaceId) {
|
||||
throw new Error(
|
||||
"Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)",
|
||||
);
|
||||
}
|
||||
const draftId = env("DRAFT_ID", "composer-keyboard");
|
||||
const session = env("SESSION", "composer-keyboard-ios");
|
||||
const device = env("DEVICE", "iPhone 16 Pro");
|
||||
const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios");
|
||||
const grownPrefix = "grown line one\ngrown line two\ngrown line three\n";
|
||||
// Partial queries with known best matches. `inserted` is the composer text after
|
||||
// tapping the best match.
|
||||
const fileCase = { text: "@app", expected: "packages/app", inserted: '"packages/app"' };
|
||||
// The release skills ship in this repository's .claude/skills.
|
||||
const skillCase = { text: "/release", expected: "/release-beta", inserted: "/release-beta " };
|
||||
// The chat fixture's mock provider lists one command.
|
||||
const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " };
|
||||
|
||||
fs.mkdirSync(artifactsDir, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
function snapshot(name) {
|
||||
const output = ad("snapshot", "--raw", "--json");
|
||||
fs.writeFileSync(path.join(artifactsDir, `${name}.json`), output);
|
||||
ad("screenshot", path.join(artifactsDir, `${name}.png`));
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
const nodeById = (snap, id) => snap.data.nodes.find((node) => node.identifier === id && node.rect);
|
||||
const keyboardShown = (snap) => snap.data.nodes.some((node) => node.type === "Keyboard");
|
||||
const composerText = (snap) =>
|
||||
snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.value ?? "";
|
||||
|
||||
async function waitFor(name, predicate, timeoutMs = 10_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const snap = snapshot(name);
|
||||
if (predicate(snap)) return snap;
|
||||
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${name}`);
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard motion has settled once two snapshots agree on the composer position.
|
||||
async function settledPlacement(name, keyboard) {
|
||||
let previousTop;
|
||||
return waitFor(name, (snap) => {
|
||||
const composer = nodeById(snap, "message-input-root");
|
||||
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
|
||||
if (keyboardShown(snap) !== keyboard) return false;
|
||||
const stable = composer.rect.y === previousTop;
|
||||
previousTop = composer.rect.y;
|
||||
return stable;
|
||||
});
|
||||
}
|
||||
|
||||
function assertPlacement(name, snap, expected) {
|
||||
const placement = resolveAutocompletePlacement(snap, { expected });
|
||||
process.stdout.write(
|
||||
`PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round(
|
||||
placement.popover.y + placement.popover.height,
|
||||
)} above composer ${Math.round(placement.composer.y)}, ${expected} drawn\n`,
|
||||
);
|
||||
return placement;
|
||||
}
|
||||
|
||||
// iOS has no generic keyboard dismissal. Tapping the chat stream dismisses it; a
|
||||
// draft has no stream, so open and close the model sheet, which takes input focus.
|
||||
async function dismissKeyboard(snap) {
|
||||
const composer = nodeById(snap, "message-input-root").rect;
|
||||
ad("press", "4", String(Math.round(composer.y - 8)));
|
||||
await sleep(1_000);
|
||||
if (!keyboardShown(snapshot("dismiss-keyboard"))) return;
|
||||
const sheetOpen = (sheet) =>
|
||||
sheet.data.nodes.some((node) => node.type === "Button" && node.label === "Close");
|
||||
ad("press", 'id="combined-model-selector"');
|
||||
await waitFor("dismiss-keyboard-sheet", sheetOpen);
|
||||
ad("press", 'label="Close"');
|
||||
// The input is not hittable until the sheet has gone.
|
||||
await waitFor("dismiss-keyboard-done", (done) => !sheetOpen(done) && !keyboardShown(done));
|
||||
}
|
||||
|
||||
// Tap past the end of the (short) last line so the caret lands after the mention.
|
||||
// The input's right padding holds the fullscreen toggle; stay clear of it.
|
||||
function focusAtEnd(snap) {
|
||||
const input = snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.rect;
|
||||
if (!input) throw new Error("Missing composer input");
|
||||
ad(
|
||||
"press",
|
||||
String(Math.round(input.x + input.width * 0.7)),
|
||||
String(Math.round(input.y + input.height - 6)),
|
||||
);
|
||||
}
|
||||
|
||||
// `fill` cannot replace a multi-line draft or write an empty one, so delete from the end.
|
||||
async function clearInput(name) {
|
||||
focusAtEnd(snapshot(`${name}-before-clear`));
|
||||
const keyboard = await waitFor(`${name}-clear`, keyboardShown);
|
||||
const deleteKey = keyboard.data.nodes.find(
|
||||
(node) => node.type === "Key" && node.label === "delete" && node.rect,
|
||||
)?.rect;
|
||||
if (!deleteKey) throw new Error("Missing keyboard delete key");
|
||||
const length = composerText(keyboard).length;
|
||||
ad(
|
||||
"press",
|
||||
String(Math.round(deleteKey.x + deleteKey.width / 2)),
|
||||
String(Math.round(deleteKey.y + deleteKey.height / 2)),
|
||||
"--count",
|
||||
String(length + 2),
|
||||
"--interval-ms",
|
||||
"40",
|
||||
);
|
||||
}
|
||||
|
||||
// The accessibility value drops a trailing space.
|
||||
const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd();
|
||||
|
||||
async function runCase(host, kind, { text, expected, inserted }) {
|
||||
const name = `${host}-${kind}`;
|
||||
await clearInput(name);
|
||||
await dismissKeyboard(snapshot(`${name}-empty`));
|
||||
// fill taps the input and types at once, so suggestions open while the keyboard opens.
|
||||
ad("fill", "editable=true", text);
|
||||
assertPlacement(
|
||||
`${name} opened while keyboard opens`,
|
||||
await settledPlacement(`${name}-opening`, true),
|
||||
expected,
|
||||
);
|
||||
await clearInput(`${name}-settled`);
|
||||
await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === "");
|
||||
ad("type", text);
|
||||
assertPlacement(
|
||||
`${name} opened after keyboard settled`,
|
||||
await settledPlacement(`${name}-open`, true),
|
||||
expected,
|
||||
);
|
||||
await dismissKeyboard(snapshot(`${name}-before-close`));
|
||||
assertPlacement(
|
||||
`${name} keyboard closed`,
|
||||
await settledPlacement(`${name}-closed`, false),
|
||||
expected,
|
||||
);
|
||||
focusAtEnd(snapshot(`${name}-before-reopen`));
|
||||
const reopened = assertPlacement(
|
||||
`${name} keyboard opened under open suggestions`,
|
||||
await settledPlacement(`${name}-reopened`, true),
|
||||
expected,
|
||||
);
|
||||
ad("press", String(reopened.x), String(reopened.y));
|
||||
const selected = await waitFor(`${name}-selected`, (snap) =>
|
||||
sameText(composerText(snap), inserted),
|
||||
);
|
||||
if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`);
|
||||
process.stdout.write(
|
||||
`PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Drafts list commands for their selected provider, and a fresh install selects none.
|
||||
async function selectClaudeModel(host) {
|
||||
ad("press", 'id="combined-model-selector"');
|
||||
await waitFor(`${host}-model-sheet`, (snap) => Boolean(nodeById(snap, "agent-controls-model")));
|
||||
ad("press", 'id="agent-controls-model"');
|
||||
await waitFor(`${host}-providers`, (snap) => Boolean(nodeById(snap, "model-provider-claude")));
|
||||
ad("press", 'id="model-provider-claude"');
|
||||
const models = await waitFor(`${host}-models`, (snap) =>
|
||||
snap.data.nodes.some((node) => node.identifier?.startsWith("model-row-claude-") && node.rect),
|
||||
);
|
||||
const row = models.data.nodes.find(
|
||||
(node) => node.identifier?.startsWith("model-row-claude-") && node.rect,
|
||||
).rect;
|
||||
ad(
|
||||
"press",
|
||||
String(Math.round(row.x + row.width / 2)),
|
||||
String(Math.round(row.y + row.height / 2)),
|
||||
);
|
||||
await waitFor(`${host}-model-selected`, (snap) =>
|
||||
Boolean(nodeById(snap, "agent-controls-thinking")),
|
||||
);
|
||||
ad("press", 'label="Close"');
|
||||
await waitFor(`${host}-model-closed`, (snap) => !nodeById(snap, "agent-controls-model"));
|
||||
}
|
||||
|
||||
async function runHost(host) {
|
||||
if (host !== "chat") await selectClaudeModel(host);
|
||||
await runCase(host, "commands", host === "chat" ? chatCommandCase : skillCase);
|
||||
await runCase(host, "files", fileCase);
|
||||
await runCase(host, "files-grown", {
|
||||
text: `${grownPrefix}${fileCase.text}`,
|
||||
expected: fileCase.expected,
|
||||
inserted: `${grownPrefix}${fileCase.inserted}`,
|
||||
});
|
||||
await clearInput(`${host}-done`);
|
||||
await dismissKeyboard(snapshot(`${host}-done`));
|
||||
}
|
||||
|
||||
const hosts = [
|
||||
["chat", `paseo://h/${serverId}/agent/${agentId}`],
|
||||
["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`],
|
||||
["new-workspace", `paseo://new?serverId=${serverId}`],
|
||||
];
|
||||
for (const [host, url] of hosts) {
|
||||
ad("open", url, "--platform", "ios", "--device", device);
|
||||
await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
|
||||
await runHost(host);
|
||||
}
|
||||
process.stdout.write(`Composer autocomplete placement passed. Artifacts: ${artifactsDir}\n`);
|
||||
@@ -0,0 +1,25 @@
|
||||
# Inline code formatting
|
||||
|
||||
This fixture renders the production `MarkdownRenderer` with plain, bold, italic,
|
||||
combined emphasis, strikethrough and linked code, plus bold code in headings,
|
||||
lists and quotes. Toggle regular/compact text. Inline code uses the theme’s
|
||||
code-to-body size ratio at every heading level; fenced code keeps the code-size
|
||||
token. Bold and links preserve the resulting size. Check the glyphs, not the background height: native backgrounds use
|
||||
line metrics.
|
||||
|
||||
Use a development client rebuilt from this checkout. Follow the temporary entry
|
||||
point and Metro workflow in [status-ring](../status-ring/README.md), importing
|
||||
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
|
||||
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
|
||||
|
||||
The package patch contains `InlineCodeFormattingTest`, which exercises actual
|
||||
Android spans in Robolectric native graphics mode, covering draw and measurement,
|
||||
span order, code inside paragraph/heading sizes, bold/italic composition, and
|
||||
links preserving code and emphasized prose fonts.
|
||||
It runs in the existing `test:android` CI job. To run only this regression locally,
|
||||
from `packages/app/android` after Expo prebuild:
|
||||
|
||||
```sh
|
||||
./gradlew :react-native-enriched-markdown:testDebugUnitTest \
|
||||
--tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button, ScrollView, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flex: 1 },
|
||||
scroll: { backgroundColor: theme.colors.surface0 },
|
||||
content: { padding: 32, paddingTop: 60 },
|
||||
title: { color: theme.colors.foreground },
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const source = [
|
||||
"Plain: `MMMMiiii`",
|
||||
"Bold: **`MMMMiiii`**",
|
||||
"Italic: *`MMMMiiii`*",
|
||||
"Both: ***`MMMMiiii`***",
|
||||
"Strike: ~~`MMMMiiii`~~",
|
||||
"Link: [`MMMMiiii`](https://example.com)",
|
||||
"# Heading **`MMMMiiii`**",
|
||||
"## Heading *`MMMMiiii`*",
|
||||
"### Heading [`MMMMiiii`](https://example.com)",
|
||||
"#### Heading ~~`MMMMiiii`~~",
|
||||
"##### Heading ***`MMMMiiii`***",
|
||||
"###### Heading `MMMMiiii`",
|
||||
"- List **`MMMMiiii`**",
|
||||
"> Quote **`MMMMiiii`**",
|
||||
"Normal **bold** and *italic* prose.",
|
||||
].join("\n\n");
|
||||
|
||||
export default function MarkdownFormattingRegression() {
|
||||
const [compact, setCompact] = useState(false);
|
||||
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.title}>Inline code formatting regression</Text>
|
||||
<Button
|
||||
title={compact ? "Use regular text" : "Use compact text"}
|
||||
onPress={toggleCompact}
|
||||
/>
|
||||
<MarkdownRenderer text={source} compact={compact} />
|
||||
</ScrollView>
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Open a connected host with the wide sidebar visible, keyboard closed, and
|
||||
// three-button navigation enabled on an Android tablet. Run against a fresh build:
|
||||
// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs <session> <adb-serial>
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , session, serial] = process.argv;
|
||||
assert(session && serial, "Pass the agent-device session and adb serial");
|
||||
const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
});
|
||||
const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/);
|
||||
assert(navBar, "A visible bottom navigation bar is required");
|
||||
assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first");
|
||||
const navigationTop = Number(navBar[1]);
|
||||
const { data } = JSON.parse(
|
||||
execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
}),
|
||||
);
|
||||
const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1];
|
||||
assert.equal(
|
||||
focusedApp,
|
||||
data.appBundleId,
|
||||
"Snapshot must belong to the focused app on this device",
|
||||
);
|
||||
for (const id of [
|
||||
"sidebar-add-project",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
]) {
|
||||
const button = data.nodes.find((node) => node.identifier === id);
|
||||
assert(button?.rect, `${id} must be visible above the system navigation bar`);
|
||||
const bottom = button.rect.y + button.rect.height;
|
||||
assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`);
|
||||
console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`);
|
||||
}
|
||||
@@ -180,6 +180,11 @@ const test = daemonTest.extend<{
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
// New installs collapse finished turns; specs assert on the rows inside them, so they
|
||||
// start from the full chat unless they seed their own settings.
|
||||
if (!localStorage.getItem("@paseo:app-settings")) {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
|
||||
}
|
||||
},
|
||||
{
|
||||
daemon: testDaemon,
|
||||
|
||||
@@ -210,3 +210,30 @@ export async function expectOneLineTruncatedFileResult(
|
||||
expect(overflow.overflow).toBe("hidden");
|
||||
expect(overflow.whiteSpace).toBe("nowrap");
|
||||
}
|
||||
|
||||
/** Hold real daemon responses until the user has finished the interaction under test. */
|
||||
export async function holdDirectorySuggestionResponses(page: Page) {
|
||||
let holding = false;
|
||||
const pending: Array<() => void> = [];
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (holding && parseSessionMessage(message)?.type === "directory_suggestions_response") {
|
||||
pending.push(() => ws.send(message));
|
||||
} else {
|
||||
ws.send(message);
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
hold() {
|
||||
holding = true;
|
||||
},
|
||||
async release() {
|
||||
await expect.poll(() => pending.length).toBeGreaterThan(0);
|
||||
holding = false;
|
||||
for (const send of pending.splice(0)) send();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { createMockIdleAgent } from "./archive-tab";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
|
||||
export async function seedSearchableCommandCenter() {
|
||||
const seed = await seedWorkspace({
|
||||
repoPrefix: "command-center-performance-",
|
||||
title: "Search workspace",
|
||||
git: false,
|
||||
});
|
||||
try {
|
||||
const agents = [];
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
agents.push(
|
||||
await createMockIdleAgent(seed.client, {
|
||||
cwd: seed.repoPath,
|
||||
workspaceId: seed.workspaceId,
|
||||
title: `Search agent ${String(index).padStart(3, "0")}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const workspaces = [];
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
const path = `${seed.repoPath}/workspace-${index}`;
|
||||
mkdirSync(path);
|
||||
const result = await seed.client.createWorkspace({
|
||||
source: { kind: "directory", path, projectId: seed.projectId },
|
||||
title: `Search workspace ${String(index).padStart(3, "0")}`,
|
||||
});
|
||||
if (!result.workspace) throw new Error(result.error ?? "Workspace creation failed");
|
||||
workspaces.push(result.workspace);
|
||||
}
|
||||
return { ...seed, agents, workspaces };
|
||||
} catch (error) {
|
||||
await seed.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function observeCommandCenterRowRenders(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchCommandCenter(panel: Locator, query: string) {
|
||||
await panel.getByTestId("command-center-input").fill(query);
|
||||
}
|
||||
|
||||
export async function warmCommandCenterRows(page: Page) {
|
||||
// FlatList fills its overscan window in timed batches after the first visible frame.
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => {
|
||||
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
const renders = await page.evaluate(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter(([id]) =>
|
||||
id.startsWith("CommandCenterResultRow:"),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } });
|
||||
}
|
||||
|
||||
export async function expectCommandCenterAgentWindow(panel: Locator, total: number) {
|
||||
const rows = panel.locator(
|
||||
'[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])',
|
||||
);
|
||||
const count = await rows.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(count).toBeLessThan(total);
|
||||
}
|
||||
|
||||
export async function scrollToOldestCommandCenterAgent(panel: Locator) {
|
||||
await panel.getByTestId("command-center-results").evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
}
|
||||
@@ -169,3 +169,42 @@ export async function expectTightTwoLineResultSpacing(
|
||||
.evaluate((element) => element.getBoundingClientRect().top);
|
||||
expect(subtitleTop - titleTop).toBeLessThanOrEqual(18);
|
||||
}
|
||||
|
||||
/** Wait for the browser to deliver wheel events before publishing a daemon update. */
|
||||
export async function scrollCommandCenterAwayFromSelection(page: Page, panel: Locator) {
|
||||
await page.setViewportSize({ width: 1280, height: 500 });
|
||||
const viewport = panel.getByTestId("command-center-results");
|
||||
await viewport.hover();
|
||||
await page.mouse.wheel(0, 300);
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeGreaterThan(100);
|
||||
const samples = await sampleCommandCenterScroll(panel);
|
||||
const offset = samples.at(-1)!;
|
||||
expect(offset).toBeGreaterThan(100);
|
||||
return offset;
|
||||
}
|
||||
|
||||
async function sampleCommandCenterScroll(panel: Locator): Promise<number[]> {
|
||||
return panel.getByTestId("command-center-results").evaluate(async (viewport) => {
|
||||
const samples: number[] = [];
|
||||
for (let frame = 0; frame < 30; frame += 1) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
samples.push(viewport.scrollTop);
|
||||
}
|
||||
return samples;
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectCommandCenterScrollToStayAt(panel: Locator, offset: number) {
|
||||
const samples = await sampleCommandCenterScroll(panel);
|
||||
expect(Math.min(...samples)).toBe(offset);
|
||||
expect(Math.max(...samples)).toBe(offset);
|
||||
}
|
||||
|
||||
export async function expectKeyboardNavigationToResume(page: Page, panel: Locator, offset: number) {
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect
|
||||
.poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop))
|
||||
.toBeLessThan(offset);
|
||||
const second = panel.getByTestId("command-center-results").getByRole("button").nth(1);
|
||||
await expect(second).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export class ImportSessionFlow {
|
||||
await expectMobileAgentSidebarVisible(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).toBeInViewport();
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export class ImportSessionFlow {
|
||||
await this.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();
|
||||
}
|
||||
async openFromWorkspaceHeader() {
|
||||
@@ -86,7 +86,29 @@ export class ImportSessionFlow {
|
||||
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
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) {
|
||||
await this.page.getByTestId("import-session-search").fill(query);
|
||||
@@ -100,7 +122,8 @@ export class ImportSessionFlow {
|
||||
}
|
||||
async loadMore() {
|
||||
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);
|
||||
}
|
||||
async retryProvider(provider: string, label: string) {
|
||||
@@ -111,6 +134,7 @@ export class ImportSessionFlow {
|
||||
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
|
||||
}
|
||||
async importSession(id: string) {
|
||||
await this.revealSession(id);
|
||||
await this.page.getByTestId(rowTestId(id)).click();
|
||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
@@ -138,7 +162,7 @@ export class ImportSessionFlow {
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
async expectCommandCenterMatch() {
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function withTimelinePlugin(
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface SeedDaemonClient {
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
setWorkspaceTitle(workspaceId: string, title: string | null): Promise<{ title: string | null }>;
|
||||
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
|
||||
clearWorkspaceAttention(workspaceId: string): Promise<void>;
|
||||
setWorkspaceLabel(input: {
|
||||
@@ -147,6 +148,7 @@ export interface SeedDaemonClient {
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
cancelAgent(agentId: string): Promise<void>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
|
||||
@@ -326,6 +326,25 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
|
||||
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||
}
|
||||
|
||||
function idleAgentReleaseSwitch(page: Page) {
|
||||
return page
|
||||
.getByTestId("host-page-idle-runtime-cleanup-card")
|
||||
.getByRole("switch", { name: "Release idle agents" });
|
||||
}
|
||||
|
||||
export async function expectIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
|
||||
const toggle = idleAgentReleaseSwitch(page);
|
||||
await expect(toggle).toBeEnabled();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(enabled));
|
||||
}
|
||||
|
||||
export async function setIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
|
||||
const toggle = idleAgentReleaseSwitch(page);
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(!enabled));
|
||||
await toggle.click();
|
||||
await expectIdleAgentRelease(page, enabled);
|
||||
}
|
||||
|
||||
export async function openHostSection(
|
||||
page: Page,
|
||||
serverId: string,
|
||||
|
||||
@@ -63,8 +63,9 @@ export async function seedSidebarFooterPreferences(
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, sidebarFooterItems }) => {
|
||||
if (localStorage.getItem(key)) return;
|
||||
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
if (settings.sidebarFooterItems) return;
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, sidebarFooterItems }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
|
||||
);
|
||||
|
||||
@@ -301,3 +301,10 @@ export async function refreshAllUsage(page: Page): Promise<void> {
|
||||
export async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
}
|
||||
|
||||
export async function expectCompactSidebarOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"ios": {
|
||||
"image": "macos-tahoe-26.6-xcode-27.1"
|
||||
},
|
||||
"env": {
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -40,7 +40,7 @@
|
||||
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
|
||||
"build:apk": "npm --prefix ../.. run build:apk",
|
||||
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
|
||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||
if (!provider) {
|
||||
return "Agent";
|
||||
}
|
||||
return provider
|
||||
.split(/[-_\s]+/)
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function resolveAgentLabel(title: string | null | undefined): string | null {
|
||||
if (typeof title !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = title.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.toLowerCase() === "new agent") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function useAgentDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const descriptorState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const session = state.sessions[context.serverId];
|
||||
const agent =
|
||||
session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null;
|
||||
return {
|
||||
provider: agent?.provider ?? "codex",
|
||||
title: agent?.title ?? null,
|
||||
status: agent?.status ?? null,
|
||||
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
|
||||
requiresAttention: agent?.requiresAttention ?? false,
|
||||
attentionReason: agent?.attentionReason ?? null,
|
||||
isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const icon = useProviderIcon(descriptorState.provider, context.serverId);
|
||||
return buildAgentDescriptor({ ...descriptorState, icon });
|
||||
}
|
||||
|
||||
export function buildAgentDescriptor(input: {
|
||||
provider: Agent["provider"];
|
||||
title: string | null;
|
||||
status: Agent["status"] | null;
|
||||
pendingPermissionCount: number;
|
||||
requiresAttention: boolean;
|
||||
attentionReason: Agent["attentionReason"];
|
||||
isTurnActive: boolean;
|
||||
icon: PanelDescriptor["icon"];
|
||||
}): PanelDescriptor {
|
||||
const provider = input.provider;
|
||||
const label = resolveAgentLabel(input.title);
|
||||
|
||||
return {
|
||||
label: label ?? "",
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
icon: input.icon,
|
||||
statusBucket: input.status
|
||||
? deriveSidebarStateBucket({
|
||||
status: input.isTurnActive ? "running" : input.status,
|
||||
pendingPermissionCount: input.pendingPermissionCount,
|
||||
requiresAttention: input.requiresAttention,
|
||||
attentionReason: input.attentionReason,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,7 @@ function viewport(root: HTMLElement, capture: Capture) {
|
||||
const handle: StreamViewportHandle = {
|
||||
scrollToBottom() {},
|
||||
prepareForViewportChange() {},
|
||||
holdReadingPosition() {},
|
||||
scrollToMessage(_messageId, occurrence) {
|
||||
if (occurrence) capture.occurrence = occurrence;
|
||||
},
|
||||
|
||||
@@ -265,6 +265,7 @@ describe("useChatOutline", () => {
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const viewportRef = { current: viewport };
|
||||
@@ -316,6 +317,7 @@ describe("useChatOutline", () => {
|
||||
current: {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
},
|
||||
};
|
||||
@@ -371,6 +373,7 @@ describe("useChatOutline", () => {
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const livePrompt = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/tim
|
||||
import type { InstalledPlugin } from "@/plugins/types";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
getStreamItemMessageId,
|
||||
hydrateStreamState,
|
||||
type StreamItem,
|
||||
type ToolCallItem,
|
||||
@@ -13,7 +14,7 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
|
||||
const runtime = {
|
||||
paseo: {},
|
||||
@@ -97,7 +98,11 @@ function pluginData(items: StreamItem[]): unknown[] {
|
||||
return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : []));
|
||||
}
|
||||
|
||||
const presentationOptions = { level: "overview" as const, isTurnActive: true };
|
||||
const presentationOptions = {
|
||||
display: "collapseToolCalls" as const,
|
||||
expandedTurnHostIds: new Set<string>(),
|
||||
isTurnActive: true,
|
||||
};
|
||||
|
||||
function streamHarness(transform?: TimelineItemTransform) {
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ChatDisplay } from "@/hooks/use-settings";
|
||||
import type { TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
import {
|
||||
@@ -5,23 +6,32 @@ import {
|
||||
projectToolCallDetailLevel,
|
||||
type PreparedToolCallHistory,
|
||||
type ToolCallDetailLevel,
|
||||
type ToolCallDetailProjection,
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
|
||||
|
||||
interface PresentationInput {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
transform: TimelineItemTransform | undefined;
|
||||
level: ToolCallDetailLevel;
|
||||
display: ChatDisplay;
|
||||
/** User messages whose collapsed turn the reader opened. */
|
||||
expandedTurnHostIds: ReadonlySet<string>;
|
||||
isTurnActive: boolean;
|
||||
}
|
||||
|
||||
/** Find and scrolling address a plugin row by the source it replaces. */
|
||||
export function getStreamItemMessageId(item: StreamItem): string {
|
||||
if (item.kind === "plugin") return item.sourceId ?? item.id;
|
||||
return item.id;
|
||||
export interface StreamPresentation extends ToolCallDetailProjection {
|
||||
turns: Omit<TurnCollapse, "tail">;
|
||||
}
|
||||
|
||||
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
||||
tail: [],
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
});
|
||||
|
||||
/** Source messages reach plugins before display projection. */
|
||||
export function createStreamPresentation() {
|
||||
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
|
||||
@@ -47,12 +57,47 @@ export function createStreamPresentation() {
|
||||
let preparedTail: StreamItem[] | undefined;
|
||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||
let collapsedSource: StreamItem[] | undefined;
|
||||
let collapsedIsLatestTurnOpen = false;
|
||||
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
|
||||
let collapsed: TurnCollapse | undefined;
|
||||
|
||||
function presentRows(item: StreamItem): StreamItem[] {
|
||||
return [item.kind === "user_message" ? presentUserMessage(item) : item];
|
||||
}
|
||||
|
||||
return (input: PresentationInput) => {
|
||||
function collapse(
|
||||
projection: ToolCallDetailProjection,
|
||||
input: PresentationInput,
|
||||
): StreamPresentation {
|
||||
if (input.display !== "collapseTurns") {
|
||||
return { ...projection, turns: NO_COLLAPSED_TURNS };
|
||||
}
|
||||
// Live text updates reach the head only, so the folded history is reused until the
|
||||
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
|
||||
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
|
||||
if (
|
||||
!collapsed ||
|
||||
collapsedSource !== projection.tail ||
|
||||
collapsedIsLatestTurnOpen !== isLatestTurnOpen ||
|
||||
collapsedExpandedHostIds !== input.expandedTurnHostIds
|
||||
) {
|
||||
collapsed = collapseTurns({
|
||||
tail: projection.tail,
|
||||
isLatestTurnOpen,
|
||||
expandedHostIds: input.expandedTurnHostIds,
|
||||
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
|
||||
});
|
||||
collapsedSource = projection.tail;
|
||||
collapsedIsLatestTurnOpen = isLatestTurnOpen;
|
||||
collapsedExpandedHostIds = input.expandedTurnHostIds;
|
||||
}
|
||||
const { tail, ...turns } = collapsed;
|
||||
return { ...projection, tail, turns };
|
||||
}
|
||||
|
||||
return (input: PresentationInput): StreamPresentation => {
|
||||
const level: ToolCallDetailLevel = input.display === "full" ? "detailed" : "overview";
|
||||
// Retained history is not reprojected or regrouped on each live text update.
|
||||
if (historySource !== input.tail || historyTransform !== input.transform) {
|
||||
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
|
||||
@@ -66,17 +111,20 @@ export function createStreamPresentation() {
|
||||
const blocks = presentRows(item);
|
||||
head.push(...blocks);
|
||||
}
|
||||
if (preparedTail !== historyRows || preparedLevel !== input.level) {
|
||||
preparedHistory = prepareToolCallHistory(input.level, historyRows);
|
||||
if (preparedTail !== historyRows || preparedLevel !== level) {
|
||||
preparedHistory = prepareToolCallHistory(level, historyRows);
|
||||
preparedTail = historyRows;
|
||||
preparedLevel = input.level;
|
||||
preparedLevel = level;
|
||||
}
|
||||
return projectToolCallDetailLevel({
|
||||
level: input.level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
});
|
||||
return collapse(
|
||||
projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
}),
|
||||
input,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,6 +361,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
bottomAnchorController.prepareForStickyViewportChange();
|
||||
markNativeViewportSettling();
|
||||
},
|
||||
holdReadingPosition: () => bottomAnchorController.detachByUser(),
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
return () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
type HistoryStartSettleScheduler,
|
||||
} from "./history-start-settle-scheduler";
|
||||
import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { useScrollToMessage } from "./use-scroll-to-message.web";
|
||||
|
||||
interface CreateWebStreamStrategyInput {
|
||||
@@ -1174,6 +1173,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
holdReadingPosition: () => {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0);
|
||||
},
|
||||
scrollToMessage,
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
@@ -1186,6 +1190,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
forceStickToBottom,
|
||||
readingAnchor,
|
||||
scheduleStickToBottom,
|
||||
scrollToMessage,
|
||||
viewportRef,
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface StreamViewportHandle {
|
||||
focus?: () => void;
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
/** Stop following output so the rows on screen stay put while rows below them open or close. */
|
||||
holdReadingPosition: () => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem, ToolCallItem } from "@/types/stream";
|
||||
import { collapseTurns, type CollapseTurnsInput } from "./model";
|
||||
|
||||
const at = new Date("2026-01-01T00:00:00.000Z");
|
||||
|
||||
function user(id: string): StreamItem {
|
||||
return { kind: "user_message", id, text: id, timestamp: at };
|
||||
}
|
||||
|
||||
function reply(id: string): StreamItem {
|
||||
return { kind: "assistant_message", id, text: id, timestamp: at };
|
||||
}
|
||||
|
||||
function thought(id: string): StreamItem {
|
||||
return { kind: "thought", id, text: id, timestamp: at, status: "ready" };
|
||||
}
|
||||
|
||||
function shell(id: string): ToolCallItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: at,
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: id,
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "shell", command: "ls" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {}) {
|
||||
return collapseTurns({
|
||||
tail,
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
||||
|
||||
describe("collapseTurns", () => {
|
||||
it("folds a finished turn's work into its user message and keeps the reply", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
thought("t1"),
|
||||
reply("early"),
|
||||
shell("c1"),
|
||||
shell("c2"),
|
||||
reply("final"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "final"]);
|
||||
expect(result.turnsByHostId.get("u1")).toEqual({
|
||||
hostId: "u1",
|
||||
expanded: false,
|
||||
summary: {
|
||||
messageCount: 1,
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
},
|
||||
});
|
||||
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
|
||||
});
|
||||
|
||||
it("shows an expanded turn's work as nested rows between the prompt and the reply", () => {
|
||||
const result = collapse([user("u1"), thought("t1"), shell("c1"), reply("final")], {
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
|
||||
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the latest turn alone while it is still producing output", () => {
|
||||
const tail = [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")];
|
||||
|
||||
const result = collapse(tail, { isLatestTurnOpen: true });
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
shell("c1"),
|
||||
reply("r1"),
|
||||
shell("after"),
|
||||
user("u2"),
|
||||
reply("r2"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("does not fold a turn that only thought before its reply", () => {
|
||||
const tail = [user("u1"), thought("t1"), reply("r1")];
|
||||
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
|
||||
const tail = [shell("c0"), reply("r0"), user("u1"), shell("c1")];
|
||||
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("counts every call a tool-call group row stands for", () => {
|
||||
const group = [shell("c1"), shell("c2"), shell("c3")];
|
||||
const result = collapse([user("u1"), group[0]!, reply("final")], {
|
||||
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
|
||||
});
|
||||
|
||||
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
|
||||
tools: { commandCount: 3 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
|
||||
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
|
||||
|
||||
export interface TurnSummary {
|
||||
messageCount: number;
|
||||
tools: OverviewSummary;
|
||||
}
|
||||
|
||||
export interface CollapsedTurn {
|
||||
/** The user message the turn answers. Its row carries the turn's summary line. */
|
||||
hostId: string;
|
||||
expanded: boolean;
|
||||
summary: TurnSummary;
|
||||
}
|
||||
|
||||
export interface TurnCollapse {
|
||||
tail: StreamItem[];
|
||||
turnsByHostId: ReadonlyMap<string, CollapsedTurn>;
|
||||
/** Rows of expanded turns, which render inside the turn's indent rail. */
|
||||
nestedItemIds: ReadonlySet<string>;
|
||||
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export interface CollapseTurnsInput {
|
||||
tail: StreamItem[];
|
||||
/** The latest turn is still producing output, so it stays unfolded. */
|
||||
isLatestTurnOpen: boolean;
|
||||
expandedHostIds: ReadonlySet<string>;
|
||||
/** The calls a tool-call group row stands for; undefined for a single call. */
|
||||
getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined;
|
||||
}
|
||||
|
||||
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
|
||||
const EMPTY_IDS: ReadonlySet<string> = new Set();
|
||||
const EMPTY_HOSTS: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
function summarizeTurn(
|
||||
items: readonly StreamItem[],
|
||||
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
|
||||
): TurnSummary {
|
||||
const calls: ToolCallItem[] = [];
|
||||
let messageCount = 0;
|
||||
for (const item of items) {
|
||||
if (item.kind === "assistant_message") messageCount += 1;
|
||||
else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||
}
|
||||
return { messageCount, tools: summarizeToolCalls(calls) };
|
||||
}
|
||||
|
||||
function hasWork(summary: TurnSummary): boolean {
|
||||
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
|
||||
}
|
||||
|
||||
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
|
||||
for (let index = end - 1; index >= start; index -= 1) {
|
||||
if (tail[index]?.kind === "assistant_message") return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds each answered turn into its user message: everything the agent did before its last
|
||||
* message collapses into one summary line, and that message stays in the chat. A turn folds
|
||||
* only once it is complete, its user message is loaded, and it sent messages or called tools
|
||||
* before its reply. Thoughts fold with the turn but are not counted.
|
||||
*/
|
||||
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const { tail } = input;
|
||||
const output: StreamItem[] = [];
|
||||
const turnsByHostId = new Map<string, CollapsedTurn>();
|
||||
const nestedItemIds = new Set<string>();
|
||||
const hostIdByFoldedMessageId = new Map<string, string>();
|
||||
|
||||
let runStart = 0;
|
||||
const flushRun = (runEnd: number) => {
|
||||
const host = tail[runStart - 1];
|
||||
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
|
||||
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
|
||||
if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const folded = tail.slice(runStart, replyIndex);
|
||||
const summary = summarizeTurn(folded, input.getGroupedCalls);
|
||||
if (!hasWork(summary)) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const expanded = input.expandedHostIds.has(host.id);
|
||||
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
|
||||
for (const item of folded) {
|
||||
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
|
||||
if (expanded) nestedItemIds.add(item.id);
|
||||
}
|
||||
if (expanded) output.push(...folded);
|
||||
output.push(...tail.slice(replyIndex, runEnd));
|
||||
};
|
||||
|
||||
for (let index = 0; index < tail.length; index += 1) {
|
||||
if (tail[index]?.kind !== "user_message") continue;
|
||||
flushRun(index);
|
||||
output.push(tail[index]!);
|
||||
runStart = index + 1;
|
||||
}
|
||||
flushRun(tail.length);
|
||||
|
||||
if (turnsByHostId.size === 0) {
|
||||
return {
|
||||
tail,
|
||||
turnsByHostId: EMPTY_TURNS,
|
||||
nestedItemIds: EMPTY_IDS,
|
||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail: output.length === tail.length ? tail : output,
|
||||
turnsByHostId,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Summary } from "lucide-react-native";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
|
||||
import type { CollapsedTurn } from "./model";
|
||||
|
||||
/**
|
||||
* The row a collapsed turn leaves under its user message, styled like a tool-call group.
|
||||
* Pressing it opens the turn's rows beneath it.
|
||||
*/
|
||||
export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
turn,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
turn: CollapsedTurn;
|
||||
onExpandedChange: (hostId: string, expanded: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { messageCount, tools } = turn.summary;
|
||||
const messageParts = useMemo(
|
||||
() =>
|
||||
messageCount > 0
|
||||
? [
|
||||
t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, {
|
||||
count: messageCount,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
[messageCount, t],
|
||||
);
|
||||
const label = useOverviewSummaryLabel(tools, messageParts);
|
||||
const toggle = useCallback(
|
||||
() => onExpandedChange(turn.hostId, !turn.expanded),
|
||||
[onExpandedChange, turn.expanded, turn.hostId],
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="turn-summary"
|
||||
label={label}
|
||||
icon={Summary}
|
||||
isExpanded={turn.expanded}
|
||||
onToggle={toggle}
|
||||
borderlessWhenExpanded
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
|
||||
import type { ScrollToMessageOccurrence } from "./strategy";
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
|
||||
// Callers reveal a message, not a row: an assistant message is several block rows and
|
||||
// the window has to open at the first of them.
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
type InlinePathTarget,
|
||||
} from "@/components/message";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
import type { TurnPresentation } from "@/timeline/turn-liveness";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
@@ -62,8 +62,10 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { QuestionFormCard } from "@/components/question-form-card";
|
||||
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
|
||||
import { IndentRail } from "@/components/indent-rail";
|
||||
import { TurnSummaryLine } from "./turn-collapse/summary-line";
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
@@ -148,9 +150,16 @@ function renderPendingPermissionsNode(input: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a row sits in an opened turn: its summary line's row, a row inside the turn, or the
|
||||
* turn's last row. The rail runs through the gaps between them so the turn reads as one block.
|
||||
*/
|
||||
type TurnIndent = "none" | "openedHost" | "nested" | "lastNested";
|
||||
|
||||
function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
layoutItem: StreamLayoutItem;
|
||||
turnIndent: TurnIndent;
|
||||
strategy: TurnContentStrategy;
|
||||
supportsTimelineCursor: boolean;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
@@ -171,7 +180,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
/>
|
||||
) : null;
|
||||
const content = (
|
||||
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}>
|
||||
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow} turnIndent={input.turnIndent}>
|
||||
{input.content}
|
||||
</StreamItemWrapper>
|
||||
);
|
||||
@@ -333,7 +342,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const chatDisplay = useSettings((settings) => settings.chatDisplay);
|
||||
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
|
||||
const contentMaxWidth = useSettings(resolveContentMaxWidth);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
@@ -357,6 +366,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
@@ -421,6 +431,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setIsNearBottom(true);
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
setExpandedToolCallGroupIds(new Set());
|
||||
setExpandedTurnHostIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const isActive = useRetainedPanelActive();
|
||||
@@ -512,7 +523,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
tail: effectiveStreamItems,
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
transform: transformTimelineItem,
|
||||
level: toolCallDetailLevel,
|
||||
display: chatDisplay,
|
||||
expandedTurnHostIds,
|
||||
isTurnActive,
|
||||
}),
|
||||
[
|
||||
@@ -520,7 +532,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
effectiveStreamItems,
|
||||
effectiveStreamHead,
|
||||
transformTimelineItem,
|
||||
toolCallDetailLevel,
|
||||
chatDisplay,
|
||||
expandedTurnHostIds,
|
||||
isTurnActive,
|
||||
],
|
||||
);
|
||||
@@ -651,6 +664,43 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[streamRenderStrategy],
|
||||
);
|
||||
|
||||
const setTurnExpanded = useCallback((hostId: string, expanded: boolean) => {
|
||||
// A turn opens below its summary line; keep that line under the reader's pointer.
|
||||
viewportRef.current?.holdReadingPosition();
|
||||
setExpandedTurnHostIds((previous) => {
|
||||
if (previous.has(hostId) === expanded) return previous;
|
||||
const next = new Set(previous);
|
||||
if (expanded) {
|
||||
next.add(hostId);
|
||||
} else {
|
||||
next.delete(hostId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// Chat find reveals a match inside a collapsed turn by opening the turn first.
|
||||
const revealLoadedMessage = useStableEvent((messageId: string): boolean => {
|
||||
const hostId = presentation.turns.hostIdByFoldedMessageId.get(messageId);
|
||||
if (hostId && !expandedTurnHostIds.has(hostId)) {
|
||||
setTurnExpanded(hostId, true);
|
||||
return true;
|
||||
}
|
||||
return revealLoadedHistory(messageId);
|
||||
});
|
||||
// Read through stable events so a collapse rerun does not change the renderer identity;
|
||||
// rows whose turn state changed are revised through `historyRowRevision`.
|
||||
const getCollapsedTurn = useStableEvent((hostId: string) =>
|
||||
presentation.turns.turnsByHostId.get(hostId),
|
||||
);
|
||||
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
if (nestedItemIds.has(layoutItem.item.id)) {
|
||||
const below = layoutItem.belowItem;
|
||||
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
|
||||
}
|
||||
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
|
||||
});
|
||||
|
||||
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
|
||||
setExpandedToolCallGroupIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
@@ -665,7 +715,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const renderUserMessageItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
return (
|
||||
const collapsedTurn = getCollapsedTurn(item.id);
|
||||
const message = (
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
@@ -684,8 +735,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (!collapsedTurn) {
|
||||
return message;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{message}
|
||||
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
|
||||
[
|
||||
context.capabilities,
|
||||
agentId,
|
||||
client,
|
||||
getCollapsedTurn,
|
||||
pendingClientMessageIds,
|
||||
resolvedServerId,
|
||||
setTurnExpanded,
|
||||
],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -893,12 +961,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderStreamItemWithTurnFooter({
|
||||
content,
|
||||
layoutItem,
|
||||
turnIndent: getTurnIndent(layoutItem),
|
||||
strategy: streamRenderStrategy,
|
||||
supportsTimelineCursor: supportsAgentForkContextCursor,
|
||||
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[
|
||||
getTurnIndent,
|
||||
handleForkAssistantTurn,
|
||||
readOnly,
|
||||
renderStreamItemContent,
|
||||
@@ -1060,13 +1130,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
const historyRowRevision = useMemo(
|
||||
() => ({
|
||||
contentById: presentation.historyGroupUpdatesByHostId,
|
||||
displayStateById: expandedToolCallGroupIds,
|
||||
displayStateById: {
|
||||
has: (id: string) =>
|
||||
expandedToolCallGroupIds.has(id) ||
|
||||
expandedTurnHostIds.has(id) ||
|
||||
nestedItemIds.has(id),
|
||||
},
|
||||
globalDisplayState: isMobile,
|
||||
}),
|
||||
[expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId],
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
expandedTurnHostIds,
|
||||
isMobile,
|
||||
nestedItemIds,
|
||||
presentation.historyGroupUpdatesByHostId,
|
||||
],
|
||||
);
|
||||
|
||||
const findItems = useMemo(
|
||||
@@ -1080,7 +1162,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
epoch={timelineEpoch}
|
||||
items={findItems}
|
||||
viewportRef={viewportRef}
|
||||
revealLoadedMessage={revealLoadedHistory}
|
||||
revealLoadedMessage={revealLoadedMessage}
|
||||
visibleMessageIds={mountedMessageIds}
|
||||
>
|
||||
<ToolCallSheetProvider>
|
||||
@@ -1763,15 +1845,41 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
interface StreamItemWrapperProps {
|
||||
itemId: string;
|
||||
gapBelow: number;
|
||||
turnIndent: TurnIndent;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) {
|
||||
function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapperProps) {
|
||||
const wrapperStyle = useMemo(
|
||||
() => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }],
|
||||
[gapBelow],
|
||||
() => [
|
||||
stylesheet.streamItemWrapper,
|
||||
{ marginBottom: turnIndent === "nested" || turnIndent === "openedHost" ? 0 : gapBelow },
|
||||
],
|
||||
[gapBelow, turnIndent],
|
||||
);
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]);
|
||||
switch (turnIndent) {
|
||||
case "none":
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
case "openedHost":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
{children}
|
||||
<IndentRail style={gapStyle} />
|
||||
</View>
|
||||
);
|
||||
case "nested":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<IndentRail style={gapStyle}>{children}</IndentRail>
|
||||
</View>
|
||||
);
|
||||
case "lastNested":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<IndentRail>{children}</IndentRail>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
|
||||
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
|
||||
import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import "@/styles/unistyles";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
@@ -71,6 +72,7 @@ import {
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { useSettingsRouteEntry } from "@/navigation/settings-selection";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||
@@ -507,7 +509,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
|
||||
return (
|
||||
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
|
||||
{surface}
|
||||
<SheetHostScope enabled={isCompactLayout}>{surface}</SheetHostScope>
|
||||
</AppContainerLifetime>
|
||||
);
|
||||
}
|
||||
@@ -758,6 +760,7 @@ function OpenProjectListener() {
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
useSettingsRouteEntry();
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
const storeReady = useStoreReady();
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
export {
|
||||
AssistantFileLinkResolverProvider,
|
||||
useAssistantFileLinkEnvironment,
|
||||
useAssistantFileLinkEnvironmentBridge,
|
||||
type AssistantFileLinkResolverProviderProps,
|
||||
} from "./provider";
|
||||
export type { AssistantFileLinkSource } from "./resolver";
|
||||
|
||||
@@ -39,6 +39,18 @@ export interface AssistantFileLinkResolverContextValue {
|
||||
const AssistantFileLinkResolverContext =
|
||||
createContext<AssistantFileLinkResolverContextValue | null>(null);
|
||||
|
||||
export function useAssistantFileLinkEnvironmentBridge() {
|
||||
const value = useContext(AssistantFileLinkResolverContext);
|
||||
return useCallback(
|
||||
(children: ReactNode) => (
|
||||
<AssistantFileLinkResolverContext.Provider value={value}>
|
||||
{children}
|
||||
</AssistantFileLinkResolverContext.Provider>
|
||||
),
|
||||
[value],
|
||||
);
|
||||
}
|
||||
|
||||
export function AssistantFileLinkResolverProvider({
|
||||
client,
|
||||
serverId,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import { AssistantFileLinkResolverProvider } from "./provider";
|
||||
@@ -99,6 +99,12 @@ function createWrapper(input: { client: TestClient; openedFiles: OpenedFile[]; t
|
||||
}
|
||||
|
||||
describe("useFileLink", () => {
|
||||
// Lookups resolve after a test's last assertion; an unmounted hook cannot re-render
|
||||
// once jsdom is torn down.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("returns the same object across no-op parent rerenders", () => {
|
||||
const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
@@ -10,12 +12,25 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation";
|
||||
import { StatusIcon } from "@/components/status-icon";
|
||||
import { useWorkspaceStatus } from "@/workspace-status";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text";
|
||||
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
@@ -69,9 +84,7 @@ import {
|
||||
buildContributionSections,
|
||||
filterAndRankBuiltInResults,
|
||||
joinSubtitleParts,
|
||||
moveActiveResultId,
|
||||
PINNED_SECTION_BAND,
|
||||
preserveActiveResultId,
|
||||
projectCommandCenterRows,
|
||||
type CommandCenterAgentResult,
|
||||
type CommandCenterFileResult,
|
||||
@@ -81,6 +94,7 @@ import {
|
||||
type CommandCenterSearchFields,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
import { createCommandCenterInteraction, type CommandCenterInteraction } from "./interaction";
|
||||
import { useWorkspaceFileSearch } from "./workspace-file-search";
|
||||
|
||||
const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({
|
||||
@@ -106,6 +120,20 @@ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearc
|
||||
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
|
||||
}
|
||||
|
||||
function createWorkspaceResultAction(serverId: string, workspaceId: string) {
|
||||
return () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToWorkspace({ serverId, workspaceId });
|
||||
};
|
||||
}
|
||||
|
||||
function createAgentResultAction(serverId: string, agentId: string) {
|
||||
return () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId, agentId });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what
|
||||
* was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke.
|
||||
@@ -115,6 +143,9 @@ function useBuiltInRows(open: boolean): {
|
||||
agents: CommandCenterAgentResult[];
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const previousRows = useRef(
|
||||
new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>(),
|
||||
);
|
||||
const { agents } = useAggregatedAgents({ demand: open });
|
||||
const { projects } = useProjects({ enabled: open });
|
||||
const showHost = useHosts().length > 1;
|
||||
@@ -136,7 +167,22 @@ function useBuiltInRows(open: boolean): {
|
||||
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
|
||||
|
||||
return useMemo(() => {
|
||||
if (!open) return { workspaces: [], agents: [] };
|
||||
if (!open) {
|
||||
previousRows.current.clear();
|
||||
return { workspaces: [], agents: [] };
|
||||
}
|
||||
// Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged
|
||||
// row data so a directory update does not invalidate every mounted ResultRow.
|
||||
const nextRows = new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>();
|
||||
const preserve = <T extends CommandCenterAgentResult | CommandCenterWorkspaceResult>(
|
||||
row: T,
|
||||
): T => {
|
||||
const previous = previousRows.current.get(row.id);
|
||||
if (previous) row.run = previous.run;
|
||||
const stable = previous && equal(previous, row) ? (previous as T) : row;
|
||||
nextRows.set(row.id, stable);
|
||||
return stable;
|
||||
};
|
||||
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
|
||||
for (const project of projects) {
|
||||
const workspaceProject = {
|
||||
@@ -147,23 +193,24 @@ function useBuiltInRows(open: boolean): {
|
||||
for (const host of project.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
if (workspace.archivingAt) continue;
|
||||
allWorkspaces.push({
|
||||
kind: "workspace",
|
||||
id: `workspace:${host.serverId}:${workspace.id}`,
|
||||
title: workspace.title ?? workspace.name,
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? host.serverName : null,
|
||||
project.projectName,
|
||||
workspace.currentBranch,
|
||||
]),
|
||||
project: workspaceProject,
|
||||
changeRequestNumber: workspace.changeRequestNumber,
|
||||
activityAt: workspace.statusEnteredAt,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
|
||||
},
|
||||
});
|
||||
allWorkspaces.push(
|
||||
preserve({
|
||||
kind: "workspace",
|
||||
id: `workspace:${host.serverId}:${workspace.id}`,
|
||||
title: workspace.title ?? workspace.name,
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? host.serverName : null,
|
||||
project.projectName,
|
||||
workspace.currentBranch,
|
||||
]),
|
||||
serverId: host.serverId,
|
||||
workspaceId: workspace.id,
|
||||
project: workspaceProject,
|
||||
changeRequestNumber: workspace.changeRequestNumber,
|
||||
activityAt: workspace.statusEnteredAt,
|
||||
run: createWorkspaceResultAction(host.serverId, workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,22 +221,20 @@ function useBuiltInRows(open: boolean): {
|
||||
const workspaceTitle = agent.workspaceId
|
||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||
: undefined;
|
||||
return {
|
||||
return preserve({
|
||||
kind: "agent",
|
||||
id: `agent:${agent.serverId}:${agent.id}`,
|
||||
agent,
|
||||
title: agent.title || t("shell.commandCenter.newAgent"),
|
||||
title: resolveAgentLabel(agent.title) ?? t("workspace.tabs.loading"),
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? agent.serverLabel : null,
|
||||
workspaceTitle ?? shortenPath(agent.cwd),
|
||||
]),
|
||||
activityAt: agent.lastActivityAt,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
||||
},
|
||||
};
|
||||
run: createAgentResultAction(agent.serverId, agent.id),
|
||||
});
|
||||
});
|
||||
previousRows.current = nextRows;
|
||||
return { workspaces: allWorkspaces, agents: agentRows };
|
||||
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
|
||||
}
|
||||
@@ -227,8 +272,6 @@ interface CommandCenterState {
|
||||
setQuery(query: string): void;
|
||||
activeId: string | null;
|
||||
rows: readonly CommandCenterListRow[];
|
||||
results: readonly CommandCenterResult[];
|
||||
rowIndexByResultId: ReadonlyMap<string, number>;
|
||||
offsets: readonly number[];
|
||||
inputRef: React.RefObject<EditingTextInputHandle | null>;
|
||||
fileSearchLoading: boolean;
|
||||
@@ -238,7 +281,10 @@ interface CommandCenterState {
|
||||
key(key: string): boolean;
|
||||
}
|
||||
|
||||
function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCenterState {
|
||||
function useCommandCenterState(
|
||||
listMetrics: CommandCenterListMetrics,
|
||||
interaction: CommandCenterInteraction,
|
||||
): CommandCenterState {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
|
||||
@@ -248,8 +294,7 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
const snapshot = useCommandCenterContributions();
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const previousOpenRef = useRef(open);
|
||||
const [query, setQueryState] = useState("");
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const { query, activeId } = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
|
||||
const builtInSections = useBuiltInSections(open, query);
|
||||
const {
|
||||
entries: fileSearchEntries,
|
||||
@@ -296,15 +341,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
),
|
||||
[builtInSections, contributionSections, fileSections, listMetrics, scope],
|
||||
);
|
||||
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
|
||||
|
||||
// Editing the query re-ranks everything, so an arrow-key selection made under the previous
|
||||
// query is no longer meaningful. Reset it in the same update as the query rather than in an
|
||||
// effect, so there is no intermediate render holding the stale selection.
|
||||
const setQuery = useCallback((next: string) => {
|
||||
setQueryState(next);
|
||||
setActiveId(null);
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
interaction.update({ open, scope, query, projection, loading: fileSearchLoading });
|
||||
}, [interaction, open, scope, query, projection, fileSearchLoading]);
|
||||
|
||||
const close = useCallback(() => setOpen(false), [setOpen]);
|
||||
const select = useCallback(
|
||||
@@ -327,18 +366,25 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Enter") {
|
||||
const selected = results.find((result) => result.id === resolvedActiveId);
|
||||
const selected = results.find((result) => result.id === activeId);
|
||||
if (!selected) return false;
|
||||
select(selected);
|
||||
return true;
|
||||
}
|
||||
if (pressed !== "ArrowDown" && pressed !== "ArrowUp") return false;
|
||||
if (results.length === 0) return false;
|
||||
const direction = pressed === "ArrowDown" ? "next" : "previous";
|
||||
setActiveId(moveActiveResultId(resolvedActiveId, results, direction));
|
||||
return true;
|
||||
return interaction.move(pressed === "ArrowDown" ? "next" : "previous");
|
||||
},
|
||||
[close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope],
|
||||
[
|
||||
close,
|
||||
open,
|
||||
projection.selectableResults,
|
||||
query,
|
||||
activeId,
|
||||
scope,
|
||||
select,
|
||||
setScope,
|
||||
interaction,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -348,8 +394,6 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setQueryState("");
|
||||
setActiveId(null);
|
||||
if (!wasOpen) return;
|
||||
const element = takeCommandCenterFocusRestoreElement();
|
||||
if (!element) return;
|
||||
@@ -367,11 +411,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
scope,
|
||||
clearScope: () => setScope(null),
|
||||
query,
|
||||
setQuery,
|
||||
activeId: resolvedActiveId,
|
||||
setQuery: interaction.setQuery,
|
||||
activeId,
|
||||
rows: projection.rows,
|
||||
results: projection.selectableResults,
|
||||
rowIndexByResultId: projection.rowIndexByResultId,
|
||||
offsets: projection.offsets,
|
||||
inputRef,
|
||||
fileSearchLoading,
|
||||
@@ -397,6 +439,7 @@ const ResultRow = memo(function ResultRow({
|
||||
active,
|
||||
onSelect,
|
||||
}: ResultRowProps) {
|
||||
recordRenderProfileReasons(`CommandCenterResultRow:${result.id}`, ["render"]);
|
||||
const press = useCallback(() => onSelect(result), [onSelect, result]);
|
||||
const choice =
|
||||
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
|
||||
@@ -430,7 +473,19 @@ const ResultRow = memo(function ResultRow({
|
||||
result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId
|
||||
}
|
||||
>
|
||||
<ResultContent result={result} query={query} />
|
||||
{(state) => (
|
||||
<ResultContent
|
||||
result={result}
|
||||
query={query}
|
||||
backdrop={
|
||||
active ||
|
||||
state.pressed ||
|
||||
Boolean((state as PressableStateCallbackType & { hovered?: boolean }).hovered)
|
||||
? "surface1"
|
||||
: "surface0"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
@@ -484,6 +539,72 @@ function SubtitleWithTimestamp({
|
||||
);
|
||||
}
|
||||
|
||||
const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function AgentResultContent({
|
||||
result,
|
||||
query,
|
||||
backdrop,
|
||||
}: {
|
||||
result: CommandCenterAgentResult;
|
||||
query: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
const agent = result.agent;
|
||||
const descriptor = useAgentDescriptor(
|
||||
{ kind: "agent", agentId: agent.id },
|
||||
{ serverId: agent.serverId },
|
||||
);
|
||||
const Icon = useMemo(
|
||||
() => withUnistyles(descriptor.icon, providerIconMapping),
|
||||
[descriptor.icon],
|
||||
);
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<StatusIcon statusBucket={descriptor.statusBucket} size={16} backdrop={backdrop}>
|
||||
<View accessibilityRole="image" accessibilityLabel={descriptor.subtitle}>
|
||||
<Icon size={16} />
|
||||
</View>
|
||||
</StatusIcon>
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SubtitleWithTimestamp
|
||||
query={query}
|
||||
subtitle={result.subtitle}
|
||||
activityAt={result.activityAt}
|
||||
testID="command-center-agent-subtitle"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceResultIcon({
|
||||
result,
|
||||
backdrop,
|
||||
}: {
|
||||
result: CommandCenterWorkspaceResult;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId);
|
||||
return (
|
||||
<StatusIcon statusBucket={statusBucket} size={16} backdrop={backdrop}>
|
||||
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
|
||||
<WorkspaceProjectIcon project={result.project} />
|
||||
</View>
|
||||
</StatusIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
|
||||
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
|
||||
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
|
||||
@@ -498,7 +619,15 @@ function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResu
|
||||
);
|
||||
}
|
||||
|
||||
function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) {
|
||||
function ResultContent({
|
||||
result,
|
||||
query,
|
||||
backdrop,
|
||||
}: {
|
||||
result: CommandCenterResult;
|
||||
query: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
if (result.kind === "file") {
|
||||
return (
|
||||
<View style={styles.rowContent}>
|
||||
@@ -525,34 +654,7 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
|
||||
);
|
||||
}
|
||||
if (result.kind === "agent") {
|
||||
const agent = result.agent;
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<AgentStatusDot
|
||||
status={agent.status}
|
||||
requiresAttention={agent.requiresAttention}
|
||||
showInactive
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SubtitleWithTimestamp
|
||||
query={query}
|
||||
subtitle={result.subtitle}
|
||||
activityAt={result.activityAt}
|
||||
testID="command-center-agent-subtitle"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return <AgentResultContent result={result} query={query} backdrop={backdrop} />;
|
||||
}
|
||||
if (result.kind === "workspace") {
|
||||
const key = result.id.slice("workspace:".length);
|
||||
@@ -560,13 +662,8 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
|
||||
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
|
||||
<View style={styles.rowMain}>
|
||||
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
|
||||
<View
|
||||
style={styles.iconSlot}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
aria-hidden
|
||||
>
|
||||
<WorkspaceProjectIcon project={result.project} />
|
||||
<View style={styles.iconSlot}>
|
||||
<WorkspaceResultIcon result={result} backdrop={backdrop} />
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
@@ -669,62 +766,39 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
|
||||
export function CommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const state = useCommandCenterState(
|
||||
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
|
||||
);
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||
const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
|
||||
const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 });
|
||||
const [interaction] = useState(() =>
|
||||
createCommandCenterInteraction((offset) => {
|
||||
const list = listRef.current ?? bottomSheetListRef.current;
|
||||
list?.scrollToOffset({ offset, animated: false });
|
||||
}),
|
||||
);
|
||||
const state = useCommandCenterState(
|
||||
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
|
||||
interaction,
|
||||
);
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: state.open,
|
||||
isEnabled: showBottomSheet,
|
||||
onClose: state.close,
|
||||
});
|
||||
|
||||
const revealActiveResult = useCallback(() => {
|
||||
if (!state.open || !state.activeId) return;
|
||||
const index = state.rowIndexByResultId.get(state.activeId);
|
||||
if (index === undefined) return;
|
||||
const { offset, visibleLength } = scrollMetricsRef.current;
|
||||
if (visibleLength <= 0) return;
|
||||
const rowTop = state.offsets[index];
|
||||
const rowBottom = rowTop + state.rows[index].height;
|
||||
let nextOffset: number | null = null;
|
||||
if (rowTop < offset) nextOffset = rowTop;
|
||||
if (rowBottom > offset + visibleLength) nextOffset = rowBottom - visibleLength;
|
||||
if (nextOffset === null) return;
|
||||
const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current;
|
||||
const boundedOffset = Math.max(0, nextOffset);
|
||||
scrollMetricsRef.current.offset = boundedOffset;
|
||||
ref?.scrollToOffset({ offset: boundedOffset, animated: false });
|
||||
}, [
|
||||
showBottomSheet,
|
||||
state.activeId,
|
||||
state.offsets,
|
||||
state.open,
|
||||
state.rowIndexByResultId,
|
||||
state.rows,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (!state.open) {
|
||||
scrollMetricsRef.current = { offset: 0, visibleLength: 0 };
|
||||
return;
|
||||
}
|
||||
revealActiveResult();
|
||||
}, [revealActiveResult, state.open]);
|
||||
const handleListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
scrollMetricsRef.current.visibleLength = event.nativeEvent.layout.height;
|
||||
revealActiveResult();
|
||||
interaction.layout(event.nativeEvent.layout.height);
|
||||
},
|
||||
[revealActiveResult],
|
||||
[interaction],
|
||||
);
|
||||
const handleListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
interaction.scrolled(event.nativeEvent.contentOffset.y);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
scrollMetricsRef.current.offset = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!showBottomSheet || !state.open) return;
|
||||
const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
|
||||
@@ -790,6 +864,14 @@ export function CommandCenter() {
|
||||
windowSize: 5,
|
||||
onLayout: handleListLayout,
|
||||
onScroll: handleListScroll,
|
||||
onScrollBeginDrag: interaction.beginManualScroll,
|
||||
onTouchStart: interaction.beginManualScroll,
|
||||
...(isWeb
|
||||
? {
|
||||
onWheel: interaction.beginManualScroll,
|
||||
onMouseDown: interaction.beginManualScroll,
|
||||
}
|
||||
: {}),
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const keyPress = useCallback(
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCommandCenterInteraction } from "./interaction";
|
||||
import { projectCommandCenterRows, type CommandCenterFileResult } from "./results";
|
||||
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
|
||||
|
||||
function results(ids = ["a", "b", "c", "d"]) {
|
||||
return projectCommandCenterRows(
|
||||
[
|
||||
{
|
||||
id: "files",
|
||||
band: 0,
|
||||
rank: 0,
|
||||
title: "Files",
|
||||
results: ids.map<CommandCenterFileResult>((id) => ({
|
||||
id,
|
||||
kind: "file",
|
||||
filePath: id,
|
||||
title: id,
|
||||
subtitle: "",
|
||||
run() {},
|
||||
})),
|
||||
},
|
||||
],
|
||||
{ singleLineRow: 40, twoLineRow: 60, sectionTitle: 20, sectionDivider: 10 },
|
||||
);
|
||||
}
|
||||
|
||||
function setup(options: { scope?: CommandCenterScope; measured?: boolean } = {}) {
|
||||
const offsets: number[] = [];
|
||||
const model = createCommandCenterInteraction((offset) => offsets.push(offset));
|
||||
const publish = (ids = ["a", "b", "c", "d"], loading = false) =>
|
||||
model.update({
|
||||
open: true,
|
||||
scope: options.scope ?? null,
|
||||
query: model.getSnapshot().query,
|
||||
projection: results(ids),
|
||||
loading,
|
||||
});
|
||||
publish();
|
||||
if (options.measured !== false) model.layout(80);
|
||||
return { model, offsets, publish };
|
||||
}
|
||||
|
||||
describe("Command Center interaction", () => {
|
||||
it("keeps manual scrolling through result refresh, removal, and resizing", () => {
|
||||
const { model, offsets, publish } = setup();
|
||||
model.beginManualScroll();
|
||||
model.scrolled(100);
|
||||
publish();
|
||||
publish(["b", "c", "d"]);
|
||||
model.layout(60);
|
||||
expect(model.getSnapshot().activeId).toBe("b");
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("reveals only clipped keyboard selections and wraps around", () => {
|
||||
const { model, offsets } = setup();
|
||||
expect(model.move("next")).toBe(true);
|
||||
expect(offsets).toEqual([20]);
|
||||
model.scrolled(20); // The programmatic scroll callback is only a measurement.
|
||||
model.move("next");
|
||||
expect(offsets).toEqual([20, 60]);
|
||||
model.move("previous");
|
||||
expect(offsets).toEqual([20, 60]);
|
||||
model.move("previous");
|
||||
expect(offsets).toEqual([20, 60, 20]);
|
||||
model.move("previous");
|
||||
expect(model.getSnapshot().activeId).toBe("d");
|
||||
expect(offsets).toEqual([20, 60, 20, 100]);
|
||||
});
|
||||
|
||||
it("keyboard navigation takes control again after manual scrolling", () => {
|
||||
const { model, offsets } = setup();
|
||||
model.beginManualScroll();
|
||||
model.scrolled(100);
|
||||
model.move("next");
|
||||
expect(model.getSnapshot().activeId).toBe("b");
|
||||
expect(offsets).toEqual([60]);
|
||||
});
|
||||
|
||||
it("a new query reveals even when the active ID stays the same", () => {
|
||||
const { model, offsets, publish } = setup();
|
||||
model.scrolled(100);
|
||||
model.setQuery("a");
|
||||
expect(offsets).toEqual([]);
|
||||
publish(["a", "b"]);
|
||||
expect(model.getSnapshot()).toEqual({ query: "a", activeId: "a" });
|
||||
expect(offsets).toEqual([20]);
|
||||
model.scrolled(100);
|
||||
publish(["a", "b"]);
|
||||
expect(offsets).toEqual([20]);
|
||||
});
|
||||
|
||||
it("an arrow command reveals a single result even when its ID cannot change", () => {
|
||||
const { model, offsets, publish } = setup();
|
||||
publish(["a"]);
|
||||
model.scrolled(30);
|
||||
model.move("next");
|
||||
expect(offsets).toEqual([20]);
|
||||
});
|
||||
|
||||
it("defers reveals until layout exists and consumes them only once", () => {
|
||||
const { model, offsets, publish } = setup({ measured: false });
|
||||
model.move("previous");
|
||||
expect(offsets).toEqual([]);
|
||||
model.layout(80);
|
||||
expect(offsets).toEqual([100]);
|
||||
model.scrolled(0);
|
||||
publish();
|
||||
model.layout(60);
|
||||
expect(offsets).toEqual([100]);
|
||||
});
|
||||
|
||||
it("manual interaction cancels an unmeasured request", () => {
|
||||
const { model, offsets } = setup({ measured: false });
|
||||
model.move("previous");
|
||||
model.beginManualScroll();
|
||||
model.layout(80);
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits for scoped search results instead of revealing the previous query's rows", () => {
|
||||
const { model, offsets, publish } = setup({ scope: "files" });
|
||||
model.scrolled(100);
|
||||
model.setQuery("new");
|
||||
publish(["a", "b"], true);
|
||||
expect(offsets).toEqual([]);
|
||||
expect(model.getSnapshot().activeId).toBeNull();
|
||||
publish(["b", "a"]);
|
||||
expect(model.getSnapshot().activeId).toBe("b");
|
||||
expect(offsets).toEqual([20]);
|
||||
});
|
||||
|
||||
it("manual interaction cancels a reveal awaiting search results", () => {
|
||||
const { model, offsets, publish } = setup({ scope: "files" });
|
||||
model.setQuery("new");
|
||||
publish([], true);
|
||||
model.beginManualScroll();
|
||||
model.scrolled(100);
|
||||
publish(["new"]);
|
||||
expect(model.getSnapshot().activeId).toBe("new");
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("empty searches complete without granting future passive results permission to scroll", () => {
|
||||
const { model, offsets, publish } = setup();
|
||||
model.setQuery("missing");
|
||||
publish([]);
|
||||
expect(model.move("next")).toBe(false);
|
||||
expect(model.getSnapshot().activeId).toBeNull();
|
||||
model.scrolled(100);
|
||||
publish(["late"]);
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("scope changes reset selection and request a reveal", () => {
|
||||
const { model, offsets } = setup();
|
||||
model.move("next");
|
||||
model.scrolled(100);
|
||||
model.update({ open: true, scope: "files", query: "", projection: results(), loading: false });
|
||||
expect(model.getSnapshot().activeId).toBe("a");
|
||||
expect(offsets).toEqual([20, 20]);
|
||||
});
|
||||
|
||||
it("close discards the old query, metrics, and deferred reveal", () => {
|
||||
const { model, offsets, publish } = setup({ measured: false });
|
||||
model.setQuery("old");
|
||||
publish();
|
||||
model.move("previous");
|
||||
model.update({ open: false, scope: null, query: "old", projection: results(), loading: false });
|
||||
model.layout(80);
|
||||
expect(model.getSnapshot()).toEqual({ query: "", activeId: null });
|
||||
expect(offsets).toEqual([]);
|
||||
publish();
|
||||
expect(model.getSnapshot().activeId).toBe("a");
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
|
||||
import {
|
||||
moveActiveResultId,
|
||||
preserveActiveResultId,
|
||||
type CommandCenterListProjection,
|
||||
} from "./results";
|
||||
|
||||
interface Snapshot {
|
||||
query: string;
|
||||
activeId: string | null;
|
||||
}
|
||||
|
||||
interface ResultsInput {
|
||||
open: boolean;
|
||||
scope: CommandCenterScope;
|
||||
query: string;
|
||||
projection: CommandCenterListProjection;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/** Selection persists; permission to move the viewport lasts for one user command. */
|
||||
export function createCommandCenterInteraction(scrollToOffset: (offset: number) => void) {
|
||||
let snapshot: Snapshot = { query: "", activeId: null };
|
||||
const listeners = new Set<() => void>();
|
||||
let open = false;
|
||||
let scope: CommandCenterScope = null;
|
||||
let projection: CommandCenterListProjection | null = null;
|
||||
let loading = false;
|
||||
let revealPending = false;
|
||||
let offset = 0;
|
||||
let visibleLength = 0;
|
||||
|
||||
function publish(next: Snapshot) {
|
||||
if (next.query === snapshot.query && next.activeId === snapshot.activeId) return;
|
||||
snapshot = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function reveal() {
|
||||
if (!open || !revealPending || !projection || visibleLength <= 0) return;
|
||||
// Scoped file search keeps its previous entries while the next query is loading.
|
||||
if (scope === "files" && loading) return;
|
||||
if (!snapshot.activeId) {
|
||||
if (!loading) revealPending = false;
|
||||
return;
|
||||
}
|
||||
const index = projection.rowIndexByResultId.get(snapshot.activeId);
|
||||
if (index === undefined) return;
|
||||
const top = projection.offsets[index];
|
||||
const bottom = top + projection.rows[index].height;
|
||||
revealPending = false;
|
||||
let nextOffset = offset;
|
||||
if (top < offset) nextOffset = top;
|
||||
if (bottom > offset + visibleLength) nextOffset = bottom - visibleLength;
|
||||
nextOffset = Math.max(0, nextOffset);
|
||||
if (nextOffset === offset) return;
|
||||
offset = nextOffset;
|
||||
scrollToOffset(nextOffset);
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
update(input: ResultsInput) {
|
||||
const resetSelection = input.open && (!open || scope !== input.scope);
|
||||
open = input.open;
|
||||
scope = input.scope;
|
||||
if (!open) {
|
||||
projection = null;
|
||||
revealPending = false;
|
||||
offset = 0;
|
||||
visibleLength = 0;
|
||||
publish({ query: "", activeId: null });
|
||||
return;
|
||||
}
|
||||
if (resetSelection) {
|
||||
revealPending = true;
|
||||
publish({ ...snapshot, activeId: null });
|
||||
}
|
||||
// A query command invalidates the old projection until React supplies its new results.
|
||||
if (input.query !== snapshot.query) return;
|
||||
projection = input.projection;
|
||||
loading = input.loading;
|
||||
if (scope !== "files" || !loading) {
|
||||
publish({
|
||||
...snapshot,
|
||||
activeId: preserveActiveResultId(snapshot.activeId, projection.selectableResults),
|
||||
});
|
||||
}
|
||||
reveal();
|
||||
},
|
||||
setQuery(query: string) {
|
||||
projection = null;
|
||||
revealPending = true;
|
||||
publish({ query, activeId: null });
|
||||
},
|
||||
move(direction: "next" | "previous"): boolean {
|
||||
if (
|
||||
!open ||
|
||||
!projection ||
|
||||
(scope === "files" && loading) ||
|
||||
projection.selectableResults.length === 0
|
||||
)
|
||||
return false;
|
||||
publish({
|
||||
...snapshot,
|
||||
activeId: moveActiveResultId(snapshot.activeId, projection.selectableResults, direction),
|
||||
});
|
||||
revealPending = true;
|
||||
reveal();
|
||||
return true;
|
||||
},
|
||||
layout(height: number) {
|
||||
visibleLength = height;
|
||||
reveal();
|
||||
},
|
||||
scrolled(nextOffset: number) {
|
||||
offset = nextOffset;
|
||||
},
|
||||
beginManualScroll() {
|
||||
revealPending = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type CommandCenterInteraction = ReturnType<typeof createCommandCenterInteraction>;
|
||||
@@ -110,6 +110,8 @@ function workspace(
|
||||
): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
serverId: "server",
|
||||
workspaceId: "workspace",
|
||||
id,
|
||||
title,
|
||||
subtitle,
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface CommandCenterWorkspaceResult {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
project: CommandCenterWorkspaceProject;
|
||||
/** PR/MR number backing this workspace, so a number query can match it exactly. */
|
||||
changeRequestNumber: number | null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { revealWorkspaceTab } from "@/workspace-tabs/navigation";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
@@ -131,8 +131,6 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
||||
openWorkspaceFileFromExplorer({
|
||||
filePath: path,
|
||||
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
closeExplorerAfterOpen: true,
|
||||
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
||||
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
|
||||
useWorkspaceLayoutStore.getState().openTab({
|
||||
workspaceKey,
|
||||
@@ -140,7 +138,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
||||
intent: "reveal",
|
||||
placement,
|
||||
}),
|
||||
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
|
||||
revealWorkspaceTab,
|
||||
});
|
||||
},
|
||||
[serverId, workspaceId],
|
||||
|
||||
@@ -11,6 +11,8 @@ function workspace(input: {
|
||||
}): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
serverId: "server",
|
||||
workspaceId: "workspace",
|
||||
id: input.id,
|
||||
title: input.title ?? input.id,
|
||||
subtitle: input.subtitle ?? "",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { router } from "expo-router";
|
||||
import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
@@ -474,6 +475,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
const openNewWorkspaceForProject = useCallback(
|
||||
(serverId: string, project: WorkspaceProjectDescriptorPayload) => {
|
||||
onClose();
|
||||
usePanelStore.getState().showMobileAgent();
|
||||
router.push(
|
||||
buildNewWorkspaceRoute({
|
||||
serverId,
|
||||
@@ -621,6 +623,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
testID: "add-project-flow-add-host",
|
||||
select: () => {
|
||||
onClose();
|
||||
usePanelStore.getState().showMobileAgent();
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,6 +28,23 @@ function failure(message: string) {
|
||||
}
|
||||
|
||||
describe("direct connection failure copy", () => {
|
||||
it.each([
|
||||
"paseo-qa-nonexistent.invalid",
|
||||
"ssl-paseo-qa.invalid",
|
||||
"tls-paseo-qa.invalid",
|
||||
"certificate-paseo-qa.invalid",
|
||||
])(
|
||||
"identifies Android DNS failures for %s without treating the hostname as TLS evidence",
|
||||
(host) => {
|
||||
const message = `Unable to resolve host "${host}": No address associated with hostname`;
|
||||
expect(failure(message)).toEqual({
|
||||
title: "Failed: tcp://example.test:443?ssl=true",
|
||||
detail: labels.hostNotFound,
|
||||
raw: `transport error — ${message}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("identifies Android certificate validation errors without calling them DNS failures", () => {
|
||||
const message =
|
||||
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
|
||||
@@ -43,6 +60,7 @@ describe("direct connection failure copy", () => {
|
||||
["SSL handshake failed", labels.tlsError],
|
||||
["TLS handshake failed", labels.tlsError],
|
||||
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
|
||||
["getaddrinfo ENOTFOUND ssl.example.test", labels.hostNotFound],
|
||||
["Host not found", labels.hostNotFound],
|
||||
["connect ECONNREFUSED", labels.refused],
|
||||
["connection timed out", labels.timedOut],
|
||||
|
||||
@@ -83,6 +83,8 @@ export function buildConnectionFailureCopy(input: {
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = labels.refused;
|
||||
} else if (rawLower.includes("unable to resolve host") || rawLower.includes("enotfound")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("certpathvalidator") ||
|
||||
@@ -91,7 +93,7 @@ export function buildConnectionFailureCopy(input: {
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail = labels.tlsError;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
} else if (rawLower.includes("not found")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = labels.hostUnreachable;
|
||||
|
||||
@@ -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 { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
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 { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ImportSessionList } from "@/components/import-session-list";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { useProviderIcon, useProviderIcons } from "@/components/provider-icons";
|
||||
@@ -371,7 +372,33 @@ function ImportSessionActivityTime({ date }: { date: string }) {
|
||||
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({
|
||||
visible,
|
||||
onClose,
|
||||
header,
|
||||
listHeader,
|
||||
listFooter,
|
||||
serverId,
|
||||
entries,
|
||||
disabled,
|
||||
@@ -379,6 +406,11 @@ function SessionRows({
|
||||
resolveFolder,
|
||||
onImportSession,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
header: SheetHeader;
|
||||
listHeader: ReactNode;
|
||||
listFooter: ReactNode;
|
||||
serverId: string | null;
|
||||
entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
|
||||
disabled: boolean;
|
||||
@@ -386,20 +418,69 @@ function SessionRows({
|
||||
resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null;
|
||||
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 (
|
||||
<View style={styles.list}>
|
||||
{entries.map((entry) => (
|
||||
<ImportSessionSheetRow
|
||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
||||
serverId={serverId}
|
||||
entry={entry}
|
||||
disabled={disabled}
|
||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||
folder={resolveFolder(entry)}
|
||||
onImportSession={onImportSession}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={header}
|
||||
testID="import-session-sheet"
|
||||
desktopMaxWidth={560}
|
||||
desktopHeight={virtualized ? "85%" : undefined}
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
scrollable={!virtualized}
|
||||
contentStyle={virtualized ? styles.listBody : undefined}
|
||||
>
|
||||
{ImportSessionList && virtualized ? (
|
||||
<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 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 (
|
||||
<AdaptiveModalSheet
|
||||
<SessionRows
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={header}
|
||||
testID="import-session-sheet"
|
||||
desktopMaxWidth={560}
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
>
|
||||
{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}
|
||||
{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>
|
||||
listHeader={listHeader}
|
||||
listFooter={listFooter}
|
||||
serverId={serverId}
|
||||
entries={visibleEntries}
|
||||
disabled={importMutation.isPending}
|
||||
importingSessionKey={importingSessionKey}
|
||||
resolveFolder={resolveFolder}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -853,9 +973,31 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
listBody: {
|
||||
gap: 0,
|
||||
},
|
||||
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],
|
||||
},
|
||||
listFooter: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
},
|
||||
listRow: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
footer: {
|
||||
paddingTop: theme.spacing[2],
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
/**
|
||||
* Collapsed chat rows badge their summary with an icon whose center sits 7px into the content
|
||||
* column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail
|
||||
* hangs from that center so opened rows read as children of the line that opened them.
|
||||
*/
|
||||
const RAIL_LEFT = 7;
|
||||
const RAIL_WIDTH = 1;
|
||||
export const INDENT_RAIL_CONTENT_OFFSET = 21;
|
||||
|
||||
/** Indents opened chat rows under the summary that collapsed them, behind a vertical line. */
|
||||
export function IndentRail({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
return <View style={[styles.rail, style]}>{children}</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
rail: {
|
||||
marginLeft: RAIL_LEFT,
|
||||
paddingLeft: INDENT_RAIL_CONTENT_OFFSET - RAIL_LEFT - RAIL_WIDTH,
|
||||
borderLeftWidth: RAIL_WIDTH,
|
||||
borderLeftColor: theme.colors.border,
|
||||
},
|
||||
}));
|
||||
@@ -166,9 +166,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
||||
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
void openProjectPicker();
|
||||
}, [showMobileAgent, openProjectPicker]);
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleOpenProjectDesktop = useCallback(() => {
|
||||
void openProjectPicker();
|
||||
@@ -205,9 +204,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
}, []);
|
||||
|
||||
const handleImportSessionMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
openImportSession();
|
||||
}, [openImportSession, showMobileAgent]);
|
||||
}, [openImportSession]);
|
||||
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
@@ -721,8 +719,11 @@ function DesktopSidebar({
|
||||
[active, resizeAnimatedStyle],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(
|
||||
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
|
||||
[insetsTop],
|
||||
() => [
|
||||
styles.desktopSidebarBorder,
|
||||
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
|
||||
],
|
||||
[insetsTop, insets.bottom],
|
||||
);
|
||||
const sidebarHeaderGroupStyle = useMemo(
|
||||
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
|
||||
|
||||
@@ -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.>"])(
|
||||
"preserves punctuation in explicit destinations: %s",
|
||||
async (source) => {
|
||||
@@ -775,3 +804,18 @@ it("makes the web Markdown container selectable in Safari as well as unprefixed
|
||||
WebkitUserSelect: "text",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"scales inline code from theme tokens with its surrounding block (compact: %s)",
|
||||
(compact) => {
|
||||
const theme = {
|
||||
...darkTheme,
|
||||
fontSize: { ...darkTheme.fontSize, code: 13, content: 17, base: 16 },
|
||||
};
|
||||
const { markdownStyle } = createMarkdownStyles(theme, { compact, blockGap: 16 });
|
||||
const bodySize = compact ? theme.fontSize.base : theme.fontSize.content;
|
||||
expect(markdownStyle.code).not.toHaveProperty("fontSize");
|
||||
expect(markdownStyle.code).toHaveProperty("fontScale", theme.fontSize.code / bodySize);
|
||||
expect(markdownStyle.codeBlock?.fontSize).toBe(theme.fontSize.code);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -23,14 +23,29 @@ const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
|
||||
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
|
||||
|
||||
// Reading the breakpoint here makes Unistyles restyle the text when the layout crosses it.
|
||||
const markdownStyle = (compact: boolean) => (theme: Theme, rt: { breakpoint?: string }) =>
|
||||
createMarkdownStyles(theme, { compact, blockGap: resolveDensity(rt, density.blockGap) });
|
||||
const markdownStyle =
|
||||
(compact: boolean, muted = false) =>
|
||||
(theme: Theme, rt: { breakpoint?: string }) =>
|
||||
createMarkdownStyles(theme, {
|
||||
compact,
|
||||
muted,
|
||||
blockGap: resolveDensity(rt, density.blockGap),
|
||||
});
|
||||
const regularStyle = markdownStyle(false);
|
||||
const compactStyle = markdownStyle(true);
|
||||
const compactMutedStyle = markdownStyle(true, true);
|
||||
|
||||
function pickMarkdownStyle(compact: boolean, muted: boolean) {
|
||||
if (!compact) return regularStyle;
|
||||
return muted ? compactMutedStyle : compactStyle;
|
||||
}
|
||||
|
||||
export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
/** Muted text colour. Compact surfaces only. */
|
||||
muted?: boolean;
|
||||
selectable?: boolean;
|
||||
streaming?: boolean;
|
||||
onLinkPress?: (url: string) => unknown;
|
||||
}
|
||||
@@ -56,6 +71,8 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
|
||||
const MarkdownPart = memo(function MarkdownPart({
|
||||
text,
|
||||
compact = false,
|
||||
muted = false,
|
||||
selectable = true,
|
||||
streaming = false,
|
||||
onLinkPress,
|
||||
...imageContext
|
||||
@@ -98,9 +115,9 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
<ThemedMarkdown
|
||||
key={`${segment.kind}:${segment.id}`}
|
||||
markdown={segment.source}
|
||||
uniProps={compact ? compactStyle : regularStyle}
|
||||
uniProps={pickMarkdownStyle(compact, muted)}
|
||||
flavor="github"
|
||||
selectable
|
||||
selectable={selectable}
|
||||
md4cFlags={MD4C_FLAGS}
|
||||
linkRecognition={linkRecognition}
|
||||
enableTaskListItemToggle={false}
|
||||
|
||||
@@ -27,11 +27,17 @@ const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PAT
|
||||
function createFilePattern(path: string): RegExp {
|
||||
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
||||
// The engine accepts regexes on both platforms. Classify complete code spans
|
||||
|
||||
@@ -7,7 +7,11 @@ const WEB_CONTAINER = { userSelect: "text", WebkitUserSelect: "text" } as const;
|
||||
|
||||
export function createMarkdownStyles(
|
||||
theme: Theme,
|
||||
{ compact = false, blockGap }: { compact?: boolean; blockGap: number },
|
||||
{
|
||||
compact = false,
|
||||
muted = false,
|
||||
blockGap,
|
||||
}: { compact?: boolean; muted?: boolean; blockGap: number },
|
||||
): { markdownStyle: MarkdownStyle; selectionColor: string; containerStyle?: typeof WEB_CONTAINER } {
|
||||
const fontSize = compact ? theme.fontSize.base : theme.fontSize.content;
|
||||
// Every block is followed by the same gap; the library drops it after the last block.
|
||||
@@ -16,7 +20,7 @@ export function createMarkdownStyles(
|
||||
fontSize,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
lineHeight: Math.round(fontSize * 1.4),
|
||||
color: theme.colors.foreground,
|
||||
color: muted ? theme.colors.foregroundMuted : theme.colors.foreground,
|
||||
...block,
|
||||
};
|
||||
const heading = (scale: number) => ({
|
||||
@@ -68,7 +72,7 @@ export function createMarkdownStyles(
|
||||
},
|
||||
code: {
|
||||
fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
fontScale: theme.fontSize.code / fontSize,
|
||||
color: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: "transparent",
|
||||
|
||||
@@ -971,6 +971,10 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
// Details that do not sit in a card leave the header as a lone highlighted pill.
|
||||
pressableExpandedDetached: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
detailWrapperBorderless: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
@@ -2048,7 +2052,10 @@ export const ExpandableBadge = memo(function ExpandableBadge({
|
||||
expandableBadgeStylesheet.pressable,
|
||||
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
|
||||
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
||||
isExpanded && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached,
|
||||
isExpanded &&
|
||||
(borderlessWhenExpanded
|
||||
? expandableBadgeStylesheet.pressableExpandedDetached
|
||||
: expandableBadgeStylesheet.pressableExpandedAttached),
|
||||
],
|
||||
[borderlessWhenExpanded, isExpanded, isInteractive, isPressed],
|
||||
);
|
||||
|
||||
@@ -10,6 +10,12 @@ import { QuestionFormCard } from "./question-form-card";
|
||||
// Load translations so controls expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// The Markdown engine does not load in this harness; question and option text are covered by
|
||||
// e2e/browser/question-prompt-pagination.spec.ts. Plain text keeps these tests on the answers.
|
||||
vi.mock("@/components/markdown/renderer", () => ({
|
||||
MarkdownRenderer: ({ text }: { text: string }) => <span>{text}</span>,
|
||||
}));
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user