mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e39f914a82 | ||
|
|
953b448774 | ||
|
|
4b90b754c0 | ||
|
|
686588c44b | ||
|
|
48623801a1 | ||
|
|
632a7f8859 | ||
|
|
48d2c9ec36 | ||
|
|
ca894d49a5 | ||
|
|
d6fa231894 | ||
|
|
fed19f3d29 | ||
|
|
74dc764f0d | ||
|
|
30d8cb0447 | ||
|
|
d91d61c6eb | ||
|
|
59b01b33f1 | ||
|
|
f3b0199c13 | ||
|
|
582beeb10b | ||
|
|
6405edd8dc | ||
|
|
4b9b2fe130 | ||
|
|
4fe006df86 | ||
|
|
21e7ccb036 | ||
|
|
89727f43bc | ||
|
|
99a1d9a7a2 | ||
|
|
3c3c272925 |
+24
-4
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.13.0-beta.1 - 2026-10-22
|
||||
## 0.13.0-beta.2 - 2026-10-23
|
||||
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
@@ -14,18 +14,25 @@ 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 under Display → Sort; workspaces no longer drag to reorder ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690))
|
||||
- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611))
|
||||
- Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580))
|
||||
- Replaced the file tab's Preview/Source toggle with a switcher between the file's panels ([#7411](https://github.com/getpaseo/paseo/pull/7411))
|
||||
- Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424))
|
||||
- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693))
|
||||
- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639))
|
||||
- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
||||
- Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
|
||||
@@ -35,18 +42,31 @@ 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))
|
||||
- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||
- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630))
|
||||
- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573))
|
||||
- Fixed parent agents never hearing from `notifyOnFinish` children after a daemon restart ([#7683](https://github.com/getpaseo/paseo/pull/7683))
|
||||
- Fixed opening a large Claude session showing an empty chat or crashing the daemon ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
|
||||
- Fixed Import session crashing the daemon with large Claude histories ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
|
||||
- Fixed hosts staying in Error when the direct connection drops instead of switching to a saved relay connection ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed tapping a notification opening a stale workspace on iOS, and Android reopening the notification's agent on every app reopen ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
|
||||
- Fixed sidebar PR status and checks disappearing after the app reconnects ([#7638](https://github.com/getpaseo/paseo/pull/7638))
|
||||
- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681))
|
||||
- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
|
||||
- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608))
|
||||
- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688))
|
||||
- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
|
||||
- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
|
||||
- Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538))
|
||||
|
||||
+24
-4
@@ -1,6 +1,6 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.13.0-beta.1 - 2026-10-22
|
||||
## 0.13.0-beta.2 - 2026-10-23
|
||||
|
||||
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
|
||||
|
||||
@@ -14,18 +14,25 @@ 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))
|
||||
- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690))
|
||||
- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611))
|
||||
- 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580))
|
||||
- 文件标签页的 Preview/Source 切换改为在该文件的各个面板之间切换 ([#7411](https://github.com/getpaseo/paseo/pull/7411))
|
||||
- Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
|
||||
- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693))
|
||||
- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639))
|
||||
- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
||||
- 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
|
||||
@@ -35,18 +42,31 @@ 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))
|
||||
- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||
- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630))
|
||||
- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573))
|
||||
- 修复了 daemon 重启后父 Agent 收不到 `notifyOnFinish` 子 Agent 通知的问题 ([#7683](https://github.com/getpaseo/paseo/pull/7683))
|
||||
- 修复了打开大型 Claude 会话时聊天为空或 daemon 崩溃的问题 ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
|
||||
- 修复了 Claude 历史记录较大时导入会话导致 daemon 崩溃的问题 ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
|
||||
- 修复了直连断开后主机停留在错误状态、不切换到已保存的 relay 连接的问题 ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- 修复了 iOS 上点击通知打开过期工作区,以及 Android 每次重新打开应用都会再次打开通知对应 Agent 的问题 ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
|
||||
- 修复了应用重新连接后侧边栏中的 PR 状态和检查消失的问题 ([#7638](https://github.com/getpaseo/paseo/pull/7638))
|
||||
- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681))
|
||||
- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
|
||||
- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608))
|
||||
- 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688))
|
||||
- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
|
||||
- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547))
|
||||
- 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538))
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+24
-1
@@ -79,7 +79,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.
|
||||
|
||||
+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
|
||||
|
||||
+1
-1
@@ -570,7 +570,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.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
|
||||
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.13.0-beta.1",
|
||||
"version": "0.13.0-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@getpaseo/server": "0.13.0-beta.2",
|
||||
"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-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.13.0-beta.1",
|
||||
"@getpaseo/relay": "0.13.0-beta.1",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@getpaseo/relay": "0.13.0-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@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-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"@getpaseo/highlight": "0.13.0-beta.2",
|
||||
"@getpaseo/plugin": "0.13.0-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@getpaseo/relay": "0.13.0-beta.2",
|
||||
"@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-beta.2",
|
||||
"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-beta.2",
|
||||
"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-beta.2",
|
||||
"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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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";
|
||||
@@ -507,7 +508,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
|
||||
return (
|
||||
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
|
||||
{surface}
|
||||
<SheetHostScope enabled={isCompactLayout}>{surface}</SheetHostScope>
|
||||
</AppContainerLifetime>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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],
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -1401,7 +1401,6 @@ function WorkspaceRowItem({
|
||||
isCreating = false,
|
||||
selectionEnabled,
|
||||
activeWorkspaceSelection,
|
||||
onWorkspacePress,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
@@ -1410,9 +1409,8 @@ function WorkspaceRowItem({
|
||||
if (!workspace.serverId) {
|
||||
return;
|
||||
}
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId });
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
}, [workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
@@ -1552,6 +1550,11 @@ function ProjectBlock({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
onToggleCollapsed,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
onWorkspaceReorder,
|
||||
useNestable,
|
||||
dragGestureHostActive,
|
||||
manualSort,
|
||||
onWorktreeCreated,
|
||||
drag,
|
||||
isDragging,
|
||||
@@ -1573,6 +1576,11 @@ function ProjectBlock({
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onToggleCollapsed: (projectViewKey: string) => void;
|
||||
onWorkspacePress?: () => void;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
useNestable: boolean;
|
||||
dragGestureHostActive?: boolean;
|
||||
manualSort: boolean;
|
||||
onWorktreeCreated?: (workspaceId: string) => void;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
@@ -1626,7 +1634,14 @@ function ProjectBlock({
|
||||
});
|
||||
|
||||
const renderWorkspaceRow = useCallback(
|
||||
(item: SidebarWorkspacePlacement) => {
|
||||
(
|
||||
item: SidebarWorkspacePlacement,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
) => {
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
key={item.workspaceKey}
|
||||
@@ -1642,6 +1657,9 @@ function ProjectBlock({
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -1660,6 +1678,29 @@ function ProjectBlock({
|
||||
],
|
||||
);
|
||||
|
||||
const renderWorkspace = useCallback(
|
||||
({
|
||||
item,
|
||||
drag: workspaceDrag,
|
||||
isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return renderWorkspaceRow(item, {
|
||||
drag: workspaceDrag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
});
|
||||
},
|
||||
[renderWorkspaceRow],
|
||||
);
|
||||
|
||||
const handleWorkspaceDragEnd = useCallback(
|
||||
(workspaces: SidebarWorkspacePlacement[]) => {
|
||||
onWorkspaceReorder(project.viewKey, workspaces);
|
||||
},
|
||||
[onWorkspaceReorder, project.viewKey],
|
||||
);
|
||||
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
||||
@@ -1725,12 +1766,29 @@ function ProjectBlock({
|
||||
if (project.workspaces.length > 0) {
|
||||
projectChildren = (
|
||||
<>
|
||||
<View
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
style={styles.workspaceListContainer}
|
||||
>
|
||||
{visibleWorkspaces.map(renderWorkspaceRow)}
|
||||
</View>
|
||||
{manualSort ? (
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
data={visibleWorkspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
style={styles.workspaceListContainer}
|
||||
>
|
||||
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
|
||||
</View>
|
||||
)}
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
@@ -1805,6 +1863,11 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
|
||||
previous.useNestable === next.useNestable &&
|
||||
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
||||
previous.manualSort === next.manualSort &&
|
||||
previous.onWorktreeCreated === next.onWorktreeCreated &&
|
||||
previous.drag === next.drag &&
|
||||
previous.isDragging === next.isDragging &&
|
||||
@@ -2114,6 +2177,9 @@ function ProjectModeList({
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
|
||||
const manualSort = useSidebarViewStore((state) => state.workspaceSort === "manual");
|
||||
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
|
||||
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
|
||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
||||
|
||||
@@ -2213,6 +2279,30 @@ function ProjectModeList({
|
||||
[getProjectOrder, setProjectOrder],
|
||||
);
|
||||
|
||||
const handleWorkspaceReorder = useCallback(
|
||||
(projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
|
||||
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
|
||||
const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey);
|
||||
if (
|
||||
!hasVisibleOrderChanged({
|
||||
currentOrder: currentWorkspaceOrder,
|
||||
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkspaceOrder(
|
||||
projectViewKey,
|
||||
mergeWithRemainder({
|
||||
currentOrder: currentWorkspaceOrder,
|
||||
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[getWorkspaceOrder, setWorkspaceOrder],
|
||||
);
|
||||
|
||||
const handleWorktreeCreated = useCallback((workspaceId: string) => {
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -2261,6 +2351,11 @@ function ProjectModeList({
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onToggleCollapsed={onToggleProjectCollapsed}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
manualSort={manualSort}
|
||||
parentGestureRef={parentGestureRef}
|
||||
onWorkspaceReorder={handleWorkspaceReorder}
|
||||
useNestable={platformIsNative}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
onWorktreeCreated={handleWorktreeCreated}
|
||||
drag={dragState.drag}
|
||||
isDragging={dragState.isDragging}
|
||||
@@ -2278,6 +2373,10 @@ function ProjectModeList({
|
||||
collapsedProjectKeys,
|
||||
activeWorkspaceSelection,
|
||||
handleWorktreeCreated,
|
||||
manualSort,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
handleWorkspaceReorder,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
supportsPinningByServerId,
|
||||
|
||||
@@ -244,10 +244,8 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
definitions.push({
|
||||
id: "sort",
|
||||
title: t("sidebar.display.sort.label"),
|
||||
content: workspaceSortSupported ? (
|
||||
<SortPage preferences={preferences} />
|
||||
) : (
|
||||
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
|
||||
content: (
|
||||
<SortPage preferences={preferences} automaticSortSupported={workspaceSortSupported} />
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -325,7 +323,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
<MenuSubTrigger
|
||||
id="sort"
|
||||
value={
|
||||
workspaceSortSupported
|
||||
workspaceSortSupported || preferences.workspaceSort === "manual"
|
||||
? t(`sidebar.display.sort.${preferences.workspaceSort}`)
|
||||
: undefined
|
||||
}
|
||||
@@ -399,19 +397,38 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
function SortPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
function SortPage({
|
||||
preferences,
|
||||
automaticSortSupported,
|
||||
}: {
|
||||
preferences: Preferences;
|
||||
automaticSortSupported: boolean;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { setWorkspaceSort } = preferences;
|
||||
const selectManual = useCallback(() => setWorkspaceSort("manual"), [setWorkspaceSort]);
|
||||
const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]);
|
||||
const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]);
|
||||
return (
|
||||
<>
|
||||
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
|
||||
{t("sidebar.display.sort.created")}
|
||||
</MenuItem>
|
||||
<MenuItem selected={preferences.workspaceSort === "lastPrompt"} onSelect={selectLastPrompt}>
|
||||
{t("sidebar.display.sort.lastPrompt")}
|
||||
<MenuItem selected={preferences.workspaceSort === "manual"} onSelect={selectManual}>
|
||||
{t("sidebar.display.sort.manual")}
|
||||
</MenuItem>
|
||||
{automaticSortSupported ? (
|
||||
<>
|
||||
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
|
||||
{t("sidebar.display.sort.created")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
selected={preferences.workspaceSort === "lastPrompt"}
|
||||
onSelect={selectLastPrompt}
|
||||
>
|
||||
{t("sidebar.display.sort.lastPrompt")}
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,14 +145,13 @@ function SidebarHistoryRow({ onBeforeNavigate }: SidebarNavRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSearchRow({ onBeforeNavigate }: SidebarNavRowProps) {
|
||||
function SidebarSearchRow() {
|
||||
const { t } = useTranslation();
|
||||
const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("search"));
|
||||
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||
const handlePress = useCallback(() => {
|
||||
onBeforeNavigate?.();
|
||||
setCommandCenterOpen(true);
|
||||
}, [onBeforeNavigate, setCommandCenterOpen]);
|
||||
}, [setCommandCenterOpen]);
|
||||
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
|
||||
@@ -512,7 +512,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
onWorkspacePress,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
@@ -543,9 +542,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!workspace.serverId) return;
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId });
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
}, [workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
return (
|
||||
<StatusWorkspaceRowWithMenu
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { CircleAlert } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { getStatusRingOffset } from "@/components/status-ring/geometry";
|
||||
import {
|
||||
STATUS_INDICATOR_ALERT_SIZE,
|
||||
STATUS_INDICATOR_DOT_SIZE,
|
||||
} from "@/utils/status-indicator-geometry";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface StatusIconProps {
|
||||
children: ReactNode;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
size?: number;
|
||||
statusDotBorderColor?: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}
|
||||
const DEFAULT_STATUS_DOT_OFFSET = -2;
|
||||
const STATUS_ALERT_OFFSET = -3;
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const needsInputAlertMapping = (theme: Theme) => ({
|
||||
color: theme.colors.surface0,
|
||||
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
});
|
||||
|
||||
export function StatusIcon({
|
||||
children,
|
||||
statusBucket,
|
||||
size = 14,
|
||||
statusDotBorderColor,
|
||||
backdrop,
|
||||
}: StatusIconProps): ReactElement {
|
||||
const bucket = statusBucket;
|
||||
const isRunning = bucket === "running";
|
||||
let statusDotColor: string | undefined;
|
||||
if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
|
||||
else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color;
|
||||
const showNeedsInputAlert = bucket === "needs_input";
|
||||
const agentIconWrapperStyle = useMemo(
|
||||
() => [styles.agentIconWrapper, { width: size, height: size }],
|
||||
[size],
|
||||
);
|
||||
const statusDotStyle = useMemo(
|
||||
() => [
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor: statusDotColor,
|
||||
borderColor: statusDotBorderColor ?? styles[backdrop].borderColor,
|
||||
},
|
||||
],
|
||||
[statusDotColor, statusDotBorderColor, backdrop],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={agentIconWrapperStyle}>
|
||||
{children}
|
||||
{isRunning ? (
|
||||
<View
|
||||
style={styles.statusRing}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel="Agent running"
|
||||
>
|
||||
<StatusRing backdrop={backdrop} />
|
||||
</View>
|
||||
) : null}
|
||||
{statusDotColor ? <View style={statusDotStyle} /> : null}
|
||||
{showNeedsInputAlert ? (
|
||||
<View style={styles.statusAlertOverlay}>
|
||||
<ThemedCircleAlert size={STATUS_INDICATOR_ALERT_SIZE} uniProps={needsInputAlertMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
agentIconWrapper: {
|
||||
position: "relative",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
statusDot: {
|
||||
position: "absolute",
|
||||
right: DEFAULT_STATUS_DOT_OFFSET,
|
||||
bottom: DEFAULT_STATUS_DOT_OFFSET,
|
||||
width: STATUS_INDICATOR_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
statusRing: {
|
||||
position: "absolute",
|
||||
right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
|
||||
bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
|
||||
},
|
||||
surface0: { borderColor: theme.colors.surface0 },
|
||||
surface1: { borderColor: theme.colors.surface1 },
|
||||
surface2: { borderColor: theme.colors.surface2 },
|
||||
surfaceSidebar: { borderColor: theme.colors.surfaceSidebar },
|
||||
surfaceSidebarHover: { borderColor: theme.colors.surfaceSidebarHover },
|
||||
surfaceSidebarSelected: { borderColor: theme.colors.surfaceSidebarSelected },
|
||||
statusAlertOverlay: {
|
||||
position: "absolute",
|
||||
right: STATUS_ALERT_OFFSET,
|
||||
bottom: STATUS_ALERT_OFFSET,
|
||||
},
|
||||
statusDotFailed: {
|
||||
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
|
||||
},
|
||||
statusDotAttention: {
|
||||
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
||||
},
|
||||
}));
|
||||
@@ -9,32 +9,20 @@ import {
|
||||
} from "react";
|
||||
import { View, useWindowDimensions } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import Animated, {
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import { KeyboardTranslateView, useKeyboardAnchor, useKeyboardShift } from "@/keyboard/shift";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
const OFFSET_FROM_ANCHOR = SPACING[3];
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface RelativeAnchorRect {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -42,14 +30,6 @@ interface RelativeAnchorRect {
|
||||
hostHeight: number;
|
||||
}
|
||||
|
||||
function measureElement(element: View): Promise<Rect> {
|
||||
return new Promise((resolve) => {
|
||||
element.measureInWindow((x, y, width, height) => {
|
||||
resolve({ x, y, width, height });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface AutocompletePopoverProps {
|
||||
visible: boolean;
|
||||
anchorRef: RefObject<View | null>;
|
||||
@@ -79,8 +59,9 @@ export function AutocompletePopover({
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
const { shift, isMoving } = useKeyboardShift();
|
||||
const measuredShift = useSharedValue(0);
|
||||
const { isMoving } = useKeyboardShift();
|
||||
const anchor = useKeyboardAnchor();
|
||||
const { measureAtRest } = anchor;
|
||||
const measurementGeneration = useRef(0);
|
||||
const canMeasure = visible && (options.length === 0 || selectedIndex >= 0);
|
||||
|
||||
@@ -90,20 +71,21 @@ export function AutocompletePopover({
|
||||
if (!anchorElement) return;
|
||||
const generation = measurementGeneration.current;
|
||||
void Promise.all([
|
||||
measureElement(anchorElement),
|
||||
measureAtRest(anchorElement),
|
||||
measureFloatingPanelPortalHost(portalHostName),
|
||||
]).then(([anchorRect, hostRect]) => {
|
||||
if (generation !== measurementGeneration.current || !hostRect) return undefined;
|
||||
if (generation !== measurementGeneration.current || !anchorRect || !hostRect) {
|
||||
return undefined;
|
||||
}
|
||||
setRelativeAnchorRect({
|
||||
x: anchorRect.x - hostRect.x,
|
||||
y: anchorRect.y - hostRect.y,
|
||||
width: anchorRect.width,
|
||||
hostHeight: hostRect.height,
|
||||
});
|
||||
measuredShift.value = shift.value;
|
||||
return undefined;
|
||||
});
|
||||
}, [anchorRef, canMeasure, measuredShift, portalHostName, shift]);
|
||||
}, [anchorRef, canMeasure, measureAtRest, portalHostName]);
|
||||
|
||||
useEffect(() => {
|
||||
measurementGeneration.current += 1;
|
||||
@@ -131,37 +113,38 @@ export function AutocompletePopover({
|
||||
[isMoving, remeasure],
|
||||
);
|
||||
|
||||
// Lay out at the anchor's resting position; the KeyboardTranslateView below lifts
|
||||
// it with the anchor. Height changes only at keyboard boundaries.
|
||||
const baseStyle = useMemo(() => {
|
||||
if (!relativeAnchorRect) return null;
|
||||
return inlineUnistylesStyle({
|
||||
position: "absolute" as const,
|
||||
left: relativeAnchorRect.x,
|
||||
width: relativeAnchorRect.width,
|
||||
bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR,
|
||||
});
|
||||
}, [relativeAnchorRect]);
|
||||
|
||||
const anchorY = relativeAnchorRect?.y ?? 0;
|
||||
const baseBottom = relativeAnchorRect
|
||||
? relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR
|
||||
: 0;
|
||||
const keyboardLayoutStyle = useAnimatedStyle(() => {
|
||||
const shiftDelta = shift.value - measuredShift.value;
|
||||
return {
|
||||
bottom: baseBottom + shiftDelta,
|
||||
maxHeight: Math.max(0, anchorY - shiftDelta - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2),
|
||||
};
|
||||
}, [anchorY, baseBottom, safeAreaInsets.top]);
|
||||
const reservedLift = anchor.reservedLift;
|
||||
const heightStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
maxHeight: Math.max(
|
||||
0,
|
||||
anchorY - reservedLift.value - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2,
|
||||
),
|
||||
}),
|
||||
[anchorY, reservedLift, safeAreaInsets.top],
|
||||
);
|
||||
|
||||
if (!visible || !relativeAnchorRect || !baseStyle) return null;
|
||||
if (options.length > 0 && selectedIndex < 0) return null;
|
||||
|
||||
// The translate view is the popover's own frame, so it lifts only the popover.
|
||||
return (
|
||||
<Portal hostName={portalHostName}>
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID="composer-autocomplete-popover"
|
||||
style={[baseStyle, keyboardLayoutStyle]}
|
||||
>
|
||||
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
|
||||
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
selectedIndex={selectedIndex}
|
||||
@@ -172,17 +155,7 @@ export function AutocompletePopover({
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</KeyboardTranslateView>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -32,9 +32,12 @@ import { useSharedValue } from "react-native-reanimated";
|
||||
export function SheetHostScope({
|
||||
children,
|
||||
style,
|
||||
enabled = true,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Disabled scopes keep their mounted children and use the enclosing host. */
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const parent = useBottomSheetModalInternal();
|
||||
const hostName = `sheet-host-${useId()}`;
|
||||
@@ -69,12 +72,14 @@ export function SheetHostScope({
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModalInternalProvider value={value}>
|
||||
<BottomSheetModalInternalProvider value={enabled ? value : parent}>
|
||||
<View style={[styles.scope, style]}>
|
||||
{children}
|
||||
<View ref={hostRef} style={styles.host} pointerEvents="box-none" onLayout={handleLayout}>
|
||||
<PortalHost name={hostName} />
|
||||
</View>
|
||||
{enabled ? (
|
||||
<View ref={hostRef} style={styles.host} pointerEvents="box-none" onLayout={handleLayout}>
|
||||
<PortalHost name={hostName} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</BottomSheetModalInternalProvider>
|
||||
);
|
||||
@@ -87,5 +92,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
host: {
|
||||
...StyleSheet.absoluteFill,
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -119,6 +119,20 @@ const SessionsContext = createContext<ComposerEditingSessions | null>(null);
|
||||
const WorkspaceContext = createContext<string | null>(null);
|
||||
const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null);
|
||||
|
||||
/** Keep the workspace's editing identity when a sheet renders in the app overlay host. */
|
||||
export function useComposerEditingEnvironmentBridge() {
|
||||
const sessions = useContext(SessionsContext);
|
||||
const workspaceKey = useContext(WorkspaceContext);
|
||||
return useCallback(
|
||||
(children: ReactNode) => (
|
||||
<SessionsContext.Provider value={sessions}>
|
||||
<WorkspaceContext.Provider value={workspaceKey}>{children}</WorkspaceContext.Provider>
|
||||
</SessionsContext.Provider>
|
||||
),
|
||||
[sessions, workspaceKey],
|
||||
);
|
||||
}
|
||||
|
||||
/** App-container ownership survives replacement of both the gesture shell and tab layout. */
|
||||
export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) {
|
||||
const [sessions] = useState(() => new ComposerEditingSessions());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { PluginFile, PluginFileTab } from "@getpaseo/plugin/client";
|
||||
import type { PluginFile, PluginTab } from "@getpaseo/plugin/client";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -69,13 +70,9 @@ export function FilePane({
|
||||
() => ({
|
||||
lineStart: location.lineStart,
|
||||
lineEnd: location.lineEnd,
|
||||
revision: navigationRevision,
|
||||
path,
|
||||
}),
|
||||
[location.lineEnd, location.lineStart, navigationRevision],
|
||||
);
|
||||
const panelTarget = useMemo(
|
||||
() => ({ serverId, workspaceRoot: root, path }),
|
||||
[path, root, serverId],
|
||||
[location.lineEnd, location.lineStart, path],
|
||||
);
|
||||
|
||||
let body;
|
||||
@@ -113,8 +110,8 @@ export function FilePane({
|
||||
key={`${panel.id}:${targetKey}`}
|
||||
panel={panel}
|
||||
file={handle.file}
|
||||
location={panelLocation}
|
||||
target={panelTarget}
|
||||
params={panelLocation}
|
||||
revision={navigationRevision}
|
||||
onModifiedChange={setModified}
|
||||
/>
|
||||
</View>
|
||||
@@ -131,21 +128,27 @@ export function FilePane({
|
||||
function MountedFilePanel({
|
||||
panel,
|
||||
file,
|
||||
location,
|
||||
target,
|
||||
params,
|
||||
revision,
|
||||
onModifiedChange,
|
||||
}: {
|
||||
panel: FilePanel;
|
||||
file: PluginFile;
|
||||
location: { lineStart?: number; lineEnd?: number; revision: number };
|
||||
target: { serverId: string; workspaceRoot: string; path: string };
|
||||
params: { path: string; lineStart?: number; lineEnd?: number };
|
||||
revision: number;
|
||||
onModifiedChange: (modified: boolean) => void;
|
||||
}) {
|
||||
const [tab] = useState<PluginFileTab>(() => ({
|
||||
setModified: (modified) => onModifiedChange(modified),
|
||||
}));
|
||||
const { state, setCurrentTabState } = usePaneContext();
|
||||
const tab = useMemo<PluginTab>(
|
||||
() => ({
|
||||
setModified: onModifiedChange,
|
||||
state,
|
||||
setState: setCurrentTabState,
|
||||
}),
|
||||
[onModifiedChange, state, setCurrentTabState],
|
||||
);
|
||||
useEffect(() => () => onModifiedChange(false), [onModifiedChange]);
|
||||
return <FilePanelView panel={panel} file={file} tab={tab} location={location} target={target} />;
|
||||
return <FilePanelView panel={panel} file={file} tab={tab} params={params} revision={revision} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useFileUrl } from "@getpaseo/plugin/client";
|
||||
import { useWorkspace, useFileUrl, type PluginFilePanelProps } from "@getpaseo/plugin/client";
|
||||
import { useCallback, useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -7,28 +7,28 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { FileHtmlPreview } from "./html-preview";
|
||||
import { FilePanelLoading } from "./loading";
|
||||
import { FileMarkdownPreview } from "./markdown-preview";
|
||||
import type { BuiltinFilePanelProps } from "./panels";
|
||||
import { TextFilePanel, type TextFilePreviewProps } from "./source/panel";
|
||||
import type { RenderingFilePanel, SourceFilePanel } from "./catalog";
|
||||
|
||||
/** Source: the text file panel with no Preview, for text files no other built-in panel claims. */
|
||||
function SourcePanel(props: BuiltinFilePanelProps) {
|
||||
function SourcePanel(props: PluginFilePanelProps) {
|
||||
return <TextFilePanel {...props} />;
|
||||
}
|
||||
|
||||
/** Markdown: the text file panel with the rendered document as its Preview. */
|
||||
function MarkdownPanel(props: BuiltinFilePanelProps) {
|
||||
function MarkdownPanel(props: PluginFilePanelProps) {
|
||||
return <TextFilePanel {...props} preview={MarkdownPreview} />;
|
||||
}
|
||||
|
||||
/** HTML: the text file panel with the sandboxed page as its Preview. */
|
||||
function HtmlPanel(props: BuiltinFilePanelProps) {
|
||||
function HtmlPanel(props: PluginFilePanelProps) {
|
||||
return <TextFilePanel {...props} preview={HtmlPreview} />;
|
||||
}
|
||||
|
||||
/** Shows Loading until the rendered Markdown has laid out once; later edits keep the content. */
|
||||
function MarkdownPreview({ text, target }: TextFilePreviewProps) {
|
||||
const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null);
|
||||
function MarkdownPreview({ text, params, workspaceId, host }: TextFilePreviewProps) {
|
||||
const client = useSessionStore((state) => state.sessions[host.id]?.client ?? null);
|
||||
const workspaceRoot = useWorkspace(workspaceId, (workspace) => workspace.directory);
|
||||
const [rendered, setRendered] = useState(false);
|
||||
const handleLayout = useCallback(() => setRendered(true), []);
|
||||
return (
|
||||
@@ -37,10 +37,10 @@ function MarkdownPreview({ text, target }: TextFilePreviewProps) {
|
||||
<View onLayout={handleLayout}>
|
||||
<FileMarkdownPreview
|
||||
source={text}
|
||||
filePath={target.path}
|
||||
workspaceRoot={target.workspaceRoot}
|
||||
filePath={params.path}
|
||||
workspaceRoot={workspaceRoot ?? ""}
|
||||
client={client}
|
||||
serverId={target.serverId}
|
||||
serverId={host.id}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -58,7 +58,7 @@ function HtmlPreview({ text }: TextFilePreviewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function ImagePanel({ file }: BuiltinFilePanelProps) {
|
||||
function ImagePanel({ file }: PluginFilePanelProps) {
|
||||
const url = useFileUrl(file);
|
||||
if (url.status === "loading") return <FilePanelLoading />;
|
||||
if (url.status === "error") return <FilePanelError message={url.error.message} />;
|
||||
@@ -83,7 +83,7 @@ const components = {
|
||||
export function BuiltinFilePanelView({
|
||||
panel,
|
||||
...props
|
||||
}: BuiltinFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) {
|
||||
}: PluginFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) {
|
||||
const Component = components[panel.id];
|
||||
return <Component {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import type { PluginFile, PluginFileLocation, PluginFileTab } from "@getpaseo/plugin/client";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import type { PluginFilePanelContribution, InstalledPlugin } from "@/plugins/types";
|
||||
import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog";
|
||||
@@ -13,14 +12,6 @@ export interface PluginFilePanel {
|
||||
}
|
||||
export type FilePanel = RenderingFilePanel | PluginFilePanel | SourceFilePanel;
|
||||
|
||||
/** A built-in panel gets the plugin panel's file, tab and location, plus where the file lives. */
|
||||
export interface BuiltinFilePanelProps {
|
||||
file: PluginFile;
|
||||
tab: PluginFileTab;
|
||||
location: PluginFileLocation;
|
||||
target: { serverId: string; workspaceRoot: string; path: string };
|
||||
}
|
||||
|
||||
export function useFilePanels(serverId: string): readonly FilePanel[] {
|
||||
const plugins = useInstalledPlugins();
|
||||
return useMemo(
|
||||
|
||||
@@ -1,54 +1,28 @@
|
||||
import { useMemo } from "react";
|
||||
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
|
||||
import { useMemo } from "react";
|
||||
import { createPluginClientStateSource } from "@/plugins/client-state/source";
|
||||
import { usePluginHostNavigation } from "@/plugins/host-navigation";
|
||||
import { PluginInstallationProvider } from "@/plugins/installation-provider";
|
||||
import { SurfaceErrorBoundary } from "@/plugins/surface-error-boundary";
|
||||
import { resolvePluginPlatform } from "@/plugins/platform";
|
||||
import { toPluginTheme } from "@/plugins/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { BuiltinFilePanelProps, PluginFilePanel } from "./panels";
|
||||
import type { PluginFilePanel } from "./panels";
|
||||
|
||||
type Props = Pick<BuiltinFilePanelProps, "file" | "tab" | "location"> & { panel: PluginFilePanel };
|
||||
|
||||
function PluginHost({ panel, file, tab, location, theme }: Props & { theme: PluginTheme }) {
|
||||
const { workspaceId } = usePaneContext();
|
||||
export function PluginFilePanelHost({
|
||||
panel,
|
||||
...props
|
||||
}: PluginFilePanelProps & { panel: PluginFilePanel }) {
|
||||
const { plugin, contribution } = panel;
|
||||
const serverId = plugin.serverId;
|
||||
const compact = useIsCompactFormFactor();
|
||||
const hosts = useHosts();
|
||||
const navigation = usePluginHostNavigation(serverId);
|
||||
const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [serverId, hostLabel]);
|
||||
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
|
||||
const stateSource = useMemo(
|
||||
() => createPluginClientStateSource(plugin.serverId),
|
||||
[plugin.serverId],
|
||||
);
|
||||
const Component = contribution.Component;
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={Component} key={panel.id}>
|
||||
<PluginInstallationProvider plugin={plugin}>
|
||||
<PluginClientStateProvider source={stateSource}>
|
||||
<Component
|
||||
theme={theme}
|
||||
host={host}
|
||||
layout={layout}
|
||||
navigation={navigation}
|
||||
workspaceId={workspaceId}
|
||||
file={file}
|
||||
tab={tab}
|
||||
location={location}
|
||||
/>
|
||||
<Component {...props} />
|
||||
</PluginClientStateProvider>
|
||||
</PluginInstallationProvider>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
const ThemedHost = withUnistyles(PluginHost);
|
||||
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
export function PluginFilePanelHost(props: Props) {
|
||||
return <ThemedHost {...props} uniProps={themeMapping} />;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
type ComponentProps,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { useFileText, type PluginFileLocation } from "@getpaseo/plugin/client";
|
||||
import {
|
||||
useFileText,
|
||||
type PluginFilePanelProps,
|
||||
type PluginFilePanelParams,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -18,7 +22,6 @@ import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { FilePanelLoading } from "../loading";
|
||||
import type { BuiltinFilePanelProps } from "../panels";
|
||||
import type { FileConflictAlertState } from "./conflict-alert";
|
||||
import { FileEditorModel, getFileConflictCallout } from "./editor/model";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
@@ -51,10 +54,12 @@ const MAX_EDITABLE_BYTES = 1024 * 1024;
|
||||
/** A rendered view of the file's text: the panel's Preview, shown instead of its source. */
|
||||
export interface TextFilePreviewProps {
|
||||
text: string;
|
||||
target: BuiltinFilePanelProps["target"];
|
||||
params: PluginFilePanelProps["params"];
|
||||
workspaceId: string;
|
||||
host: PluginFilePanelProps["host"];
|
||||
}
|
||||
|
||||
type TextFilePanelProps = BuiltinFilePanelProps & {
|
||||
type TextFilePanelProps = PluginFilePanelProps & {
|
||||
/** The panel's Preview; with one, the toolbar switches between Preview and Source. */
|
||||
preview?: ComponentType<TextFilePreviewProps>;
|
||||
};
|
||||
@@ -78,9 +83,10 @@ export function TextFilePanel(props: TextFilePanelProps) {
|
||||
* each later line target switches to Source again.
|
||||
*/
|
||||
function useTextFileView(
|
||||
location: PluginFileLocation,
|
||||
params: PluginFilePanelParams,
|
||||
revision: number,
|
||||
): [TextFileView, (view: TextFileView) => void] {
|
||||
const lineTarget = location.lineStart ? location.revision : null;
|
||||
const lineTarget = params.lineStart ? revision : null;
|
||||
const [view, setView] = useState<TextFileView>(lineTarget === null ? "preview" : "source");
|
||||
const [shownLineTarget, setShownLineTarget] = useState(lineTarget);
|
||||
if (lineTarget !== null && lineTarget !== shownLineTarget) {
|
||||
@@ -102,17 +108,19 @@ function TooLargeTextFile({ size }: { size: number }) {
|
||||
|
||||
function ReadonlyTextFile({
|
||||
file,
|
||||
location,
|
||||
target,
|
||||
params,
|
||||
revision,
|
||||
workspaceId,
|
||||
host,
|
||||
preview: Preview,
|
||||
presentation,
|
||||
}: TextFilePanelProps & { presentation: "highlighted" | "plain" }) {
|
||||
const text = useFileText(file);
|
||||
const { t } = useTranslation();
|
||||
const [view, setView] = useTextFileView(location);
|
||||
const [view, setView] = useTextFileView(params, revision);
|
||||
const sourceLocation = useMemo(
|
||||
() => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }),
|
||||
[location.lineEnd, location.lineStart, target.path],
|
||||
() => ({ path: params.path, lineStart: params.lineStart, lineEnd: params.lineEnd }),
|
||||
[params.lineEnd, params.lineStart, params.path],
|
||||
);
|
||||
if (text.status === "loading") return <FilePanelLoading />;
|
||||
if (text.status === "error") {
|
||||
@@ -132,13 +140,13 @@ function ReadonlyTextFile({
|
||||
onViewChange={Preview ? setView : undefined}
|
||||
/>
|
||||
{showPreview ? (
|
||||
<Preview text={text.text} target={target} />
|
||||
<Preview text={text.text} params={params} workspaceId={workspaceId} host={host} />
|
||||
) : (
|
||||
<ThemedFileSourceView
|
||||
content={text.text}
|
||||
filename={target.path}
|
||||
filename={params.path}
|
||||
location={sourceLocation}
|
||||
navigationRevision={location.revision}
|
||||
navigationRevision={revision}
|
||||
presentation={presentation}
|
||||
/>
|
||||
)}
|
||||
@@ -146,11 +154,19 @@ function ReadonlyTextFile({
|
||||
);
|
||||
}
|
||||
|
||||
function EditableTextFile({ file, tab, location, target, preview: Preview }: TextFilePanelProps) {
|
||||
function EditableTextFile({
|
||||
file,
|
||||
tab,
|
||||
params,
|
||||
revision,
|
||||
workspaceId,
|
||||
host,
|
||||
preview: Preview,
|
||||
}: TextFilePanelProps) {
|
||||
const { settings } = useAppSettings();
|
||||
const { t } = useTranslation();
|
||||
const [model] = useState(() => new FileEditorModel({ file }));
|
||||
const [view, setView] = useTextFileView(location);
|
||||
const [view, setView] = useTextFileView(params, revision);
|
||||
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
||||
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
@@ -181,8 +197,8 @@ function EditableTextFile({ file, tab, location, target, preview: Preview }: Tex
|
||||
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
|
||||
const handleRetry = useCallback(() => void model.reload(), [model]);
|
||||
const editorLocation = useMemo(
|
||||
() => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }),
|
||||
[location.lineEnd, location.lineStart, target.path],
|
||||
() => ({ path: params.path, lineStart: params.lineStart, lineEnd: params.lineEnd }),
|
||||
[params.lineEnd, params.lineStart, params.path],
|
||||
);
|
||||
|
||||
if (snapshot.status === "loading") return <FilePanelLoading />;
|
||||
@@ -222,13 +238,13 @@ function EditableTextFile({ file, tab, location, target, preview: Preview }: Tex
|
||||
conflict={conflict}
|
||||
/>
|
||||
{showPreview ? (
|
||||
<Preview text={snapshot.content} target={target} />
|
||||
<Preview text={snapshot.content} params={params} workspaceId={workspaceId} host={host} />
|
||||
) : (
|
||||
<SourceEditor
|
||||
model={model}
|
||||
filename={target.path}
|
||||
filename={params.path}
|
||||
location={editorLocation}
|
||||
navigationRevision={location.revision}
|
||||
navigationRevision={revision}
|
||||
vimEnabled={settings.vimKeybindings}
|
||||
onCursorChange={setCursor}
|
||||
onVimModeChange={setVimMode}
|
||||
|
||||
@@ -1,9 +1,43 @@
|
||||
import type { FilePanel, BuiltinFilePanelProps } from "./panels";
|
||||
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
|
||||
import { createPluginClientStateSource } from "@/plugins/client-state/source";
|
||||
import { useMemo } from "react";
|
||||
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { usePluginHostNavigation } from "@/plugins/host-navigation";
|
||||
import { resolvePluginPlatform } from "@/plugins/platform";
|
||||
import { toPluginTheme } from "@/plugins/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { FilePanel } from "./panels";
|
||||
import { BuiltinFilePanelView } from "./builtin";
|
||||
import { PluginFilePanelHost } from "./plugin-host";
|
||||
|
||||
/** Renders a file panel as the file tab's body. The panel owns its loading, errors and saving. */
|
||||
export function FilePanelView({ panel, ...props }: BuiltinFilePanelProps & { panel: FilePanel }) {
|
||||
if (panel.builtin) return <BuiltinFilePanelView {...props} panel={panel} />;
|
||||
type Props = Pick<PluginFilePanelProps, "file" | "tab" | "params" | "revision"> & {
|
||||
panel: FilePanel;
|
||||
};
|
||||
function FilePanelHost({ panel, theme, ...input }: Props & { theme: PluginTheme }) {
|
||||
const { workspaceId, serverId } = usePaneContext();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const hosts = useHosts();
|
||||
const label = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const host = useMemo(() => ({ id: serverId, label }), [serverId, label]);
|
||||
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
|
||||
const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
|
||||
const navigation = usePluginHostNavigation(serverId);
|
||||
const props: PluginFilePanelProps = { ...input, workspaceId, theme, host, layout, navigation };
|
||||
if (panel.builtin)
|
||||
return (
|
||||
<PluginClientStateProvider source={stateSource}>
|
||||
<BuiltinFilePanelView {...props} panel={panel} />
|
||||
</PluginClientStateProvider>
|
||||
);
|
||||
return <PluginFilePanelHost {...props} panel={panel} />;
|
||||
}
|
||||
const ThemedHost = withUnistyles(FilePanelHost);
|
||||
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
export function FilePanelView(props: Props) {
|
||||
return <ThemedHost {...props} uniProps={themeMapping} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import React, { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { buildDiffDocumentModel, retainReusableModels, reviewGeometryKey } from "./model";
|
||||
import type {
|
||||
@@ -162,6 +162,16 @@ function paletteKey(palette: DiffPalette): string {
|
||||
|
||||
const WorkspaceCacheContext = createContext<DiffDocumentWorkspaceCache | null>(null);
|
||||
|
||||
export function useDiffDocumentWorkspaceCacheBridge() {
|
||||
const cache = useDiffDocumentWorkspaceCache();
|
||||
return useCallback(
|
||||
(children: ReactNode) => (
|
||||
<WorkspaceCacheContext.Provider value={cache}>{children}</WorkspaceCacheContext.Provider>
|
||||
),
|
||||
[cache],
|
||||
);
|
||||
}
|
||||
|
||||
export function DiffDocumentWorkspaceCacheProvider({ children }: { children: ReactNode }) {
|
||||
const [cache] = useState(createDiffDocumentWorkspaceCache);
|
||||
return <WorkspaceCacheContext.Provider value={cache}>{children}</WorkspaceCacheContext.Provider>;
|
||||
|
||||
@@ -617,7 +617,7 @@ describe("computeSidebarOrderUpdates", () => {
|
||||
persistedProjectOrder: ["stale-project"],
|
||||
});
|
||||
|
||||
expect(updates).toEqual({ projectOrder: null });
|
||||
expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] });
|
||||
});
|
||||
|
||||
it("appends unseen projects without changing saved project order", () => {
|
||||
@@ -979,3 +979,20 @@ it("retains unrelated projects and keyed row placements when one project reorder
|
||||
expect(after.projects[0].workspaces[0]).toBe(before.projects[0].workspaces[1]);
|
||||
expect(after.projects[0].workspaces[1]).toBe(before.projects[0].workspaces[0]);
|
||||
});
|
||||
|
||||
it("prepends newly discovered manual workspaces without dropping filtered saved keys", () => {
|
||||
const projects = [sidebarProject({ projectKey: "p", workspaceKeys: ["new", "b", "a"] })];
|
||||
expect(
|
||||
computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder: ["p"],
|
||||
getWorkspaceOrder: () => ["srv:hidden", "srv:a", "srv:b"],
|
||||
}),
|
||||
).toEqual({
|
||||
projectOrder: null,
|
||||
workspaceOrders: [{ projectViewKey: "p", order: ["srv:new", "srv:hidden", "srv:a", "srv:b"] }],
|
||||
});
|
||||
expect(
|
||||
computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["p"] }).workspaceOrders,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
|
||||
import equal from "fast-deep-equal";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
import { selectPrHintFromStatus } from "@/git/pr-hint";
|
||||
@@ -124,11 +125,6 @@ export function areSidebarWorkspaceSessionsEqual(
|
||||
return true;
|
||||
}
|
||||
|
||||
interface EffectiveWorkspaceStatus {
|
||||
status: WorkspaceDescriptor["status"];
|
||||
enteredAt: Date | null;
|
||||
}
|
||||
|
||||
function projectNameForWorkspace(workspace: WorkspaceDescriptor): string {
|
||||
return (
|
||||
workspace.projectCustomName ??
|
||||
@@ -189,51 +185,6 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
|
||||
const EMPTY_WORKSPACE_LABELS: string[] = [];
|
||||
|
||||
function deriveEffectiveWorkspaceStatus(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
}): EffectiveWorkspaceStatus {
|
||||
if (input.workspace.status !== "done") {
|
||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||
}
|
||||
|
||||
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
});
|
||||
if (pendingStartedAt) {
|
||||
return { status: "running", enteredAt: pendingStartedAt };
|
||||
}
|
||||
|
||||
const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id);
|
||||
if (rootAgentActivity && rootAgentActivity.status !== "done") {
|
||||
return rootAgentActivity;
|
||||
}
|
||||
|
||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||
}
|
||||
|
||||
function getPendingInitialAgentCreateStartedAt(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
pendingCreateAttempts: Record<string, PendingCreateAttempt> | undefined;
|
||||
}): Date | null {
|
||||
let latestStartedAt: Date | null = null;
|
||||
for (const pending of Object.values(input.pendingCreateAttempts ?? {})) {
|
||||
if (pending.serverId !== input.serverId) continue;
|
||||
if (pending.workspaceId !== input.workspaceId) continue;
|
||||
if (pending.lifecycle === "abandoned") continue;
|
||||
const startedAt = new Date(pending.timestamp);
|
||||
if (!latestStartedAt || startedAt > latestStartedAt) {
|
||||
latestStartedAt = startedAt;
|
||||
}
|
||||
}
|
||||
return latestStartedAt;
|
||||
}
|
||||
|
||||
export interface ProjectStatusSession {
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||
@@ -584,21 +535,57 @@ export function appendMissingOrderKeys(input: {
|
||||
return [...input.currentOrder, ...missingKeys];
|
||||
}
|
||||
|
||||
export function prependMissingOrderKeys(input: {
|
||||
currentOrder: string[];
|
||||
visibleKeys: string[];
|
||||
}): string[] {
|
||||
if (input.visibleKeys.length === 0) {
|
||||
return input.currentOrder;
|
||||
}
|
||||
|
||||
const existingKeys = new Set(input.currentOrder);
|
||||
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key));
|
||||
if (missingKeys.length === 0) {
|
||||
return input.currentOrder;
|
||||
}
|
||||
|
||||
return [...missingKeys, ...input.currentOrder];
|
||||
}
|
||||
|
||||
export interface SidebarOrderUpdates {
|
||||
projectOrder: string[] | null;
|
||||
workspaceOrders: Array<{ projectViewKey: string; order: string[] }>;
|
||||
}
|
||||
|
||||
export function computeSidebarOrderUpdates(input: {
|
||||
projects: SidebarProjectEntry[];
|
||||
persistedProjectOrder: string[];
|
||||
getWorkspaceOrder?: (projectViewKey: string) => string[];
|
||||
}): SidebarOrderUpdates {
|
||||
if (input.projects.length === 0) {
|
||||
return { projectOrder: null, workspaceOrders: [] };
|
||||
}
|
||||
|
||||
const nextProjectOrder = appendMissingOrderKeys({
|
||||
currentOrder: input.persistedProjectOrder,
|
||||
visibleKeys: input.projects.map((project) => project.viewKey),
|
||||
});
|
||||
return {
|
||||
projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder,
|
||||
};
|
||||
const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder;
|
||||
|
||||
const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = [];
|
||||
for (const project of input.projects) {
|
||||
if (!input.getWorkspaceOrder) break;
|
||||
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey);
|
||||
const nextWorkspaceOrder = prependMissingOrderKeys({
|
||||
currentOrder: persistedWorkspaceOrder,
|
||||
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
||||
});
|
||||
if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
|
||||
workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder });
|
||||
}
|
||||
}
|
||||
|
||||
return { projectOrder, workspaceOrders };
|
||||
}
|
||||
|
||||
export interface SidebarLoadingState {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { type Href, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useHostChooser } from "@/hosts/host-chooser";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
|
||||
interface UseImportSessionOptions {
|
||||
@@ -31,7 +30,6 @@ interface ImportedAgentTarget {
|
||||
export function useNavigateToImportedAgent(
|
||||
serverId: string | null | undefined,
|
||||
): (agent: ImportedAgentTarget) => Promise<void> {
|
||||
const router = useRouter();
|
||||
const normalizedServerId = serverId?.trim() || null;
|
||||
const openProject = useOpenProject(normalizedServerId);
|
||||
|
||||
@@ -40,10 +38,10 @@ export function useNavigateToImportedAgent(
|
||||
if (!normalizedServerId) return;
|
||||
const project = await openProject(agent.cwd);
|
||||
if (project.ok) {
|
||||
router.push(buildHostAgentDetailRoute(normalizedServerId, agent.id) as Href);
|
||||
navigateToAgent({ serverId: normalizedServerId, agentId: agent.id });
|
||||
}
|
||||
},
|
||||
[normalizedServerId, openProject, router],
|
||||
[normalizedServerId, openProject],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
saveAppSettings as saveAppSettingsPure,
|
||||
type AppSettings,
|
||||
type AppSettingsUpdate,
|
||||
type ChatDisplay,
|
||||
type OpenInSidePanePreferences,
|
||||
type PullRequestOpenLocation,
|
||||
type DesktopSettingsBridge,
|
||||
@@ -84,6 +85,7 @@ export {
|
||||
export type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
ChatDisplay,
|
||||
AppLanguage,
|
||||
OpenInSidePanePreferences,
|
||||
PullRequestOpenLocation,
|
||||
|
||||
@@ -185,6 +185,7 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
|
||||
expect(result).toEqual(DEFAULT_CLIENT_SETTINGS);
|
||||
expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system");
|
||||
expect(DEFAULT_CLIENT_SETTINGS.chatDisplay).toBe("collapseTurns");
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(
|
||||
DEFAULT_CLIENT_SETTINGS,
|
||||
);
|
||||
@@ -454,6 +455,7 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
theme: "dark",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({
|
||||
manageBuiltInDaemon: false,
|
||||
@@ -592,6 +594,7 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -636,6 +639,7 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
@@ -657,6 +661,7 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -732,7 +737,7 @@ describe("saveAppSettings", () => {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
toolCallDetailLevel: "overview",
|
||||
chatDisplay: "collapseToolCalls",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -787,27 +792,45 @@ describe("appearance settings", () => {
|
||||
expect(result.contentFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE);
|
||||
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
|
||||
expect(result.syntaxTheme).toBe("one");
|
||||
expect(result.toolCallDetailLevel).toBe("detailed");
|
||||
expect(result.chatDisplay).toBe("full");
|
||||
});
|
||||
|
||||
it("migrates the enabled compact tool call preference to overview", async () => {
|
||||
it("migrates the enabled compact tool call preference to collapsed tool calls", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ compactToolCalls: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseToolCalls");
|
||||
});
|
||||
|
||||
it("clears settings with an unrecognized tool call detail level", async () => {
|
||||
it.each([
|
||||
["overview", "collapseToolCalls"],
|
||||
["concise", "collapseToolCalls"],
|
||||
["detailed", "full"],
|
||||
["unknown", "full"],
|
||||
])("reads the %s tool call detail level as the %s chat display", async (legacy, display) => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }),
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: legacy }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed");
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe(display);
|
||||
});
|
||||
|
||||
it("keeps a stored chat display over the tool call detail level it replaced", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
chatDisplay: "collapseTurns",
|
||||
toolCallDetailLevel: "detailed",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseTurns");
|
||||
});
|
||||
|
||||
it("migrates a switched-off checks row item to the hidden checks display", async () => {
|
||||
|
||||
@@ -40,7 +40,8 @@ export type WorkspaceTitleSource = "title" | "branch";
|
||||
export type PullRequestOpenLocation = "main" | "side" | "explorer";
|
||||
/** What a sidebar workspace row shows in the space to the right of its title. */
|
||||
export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
|
||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||
/** How much of an agent's work the chat shows between the user's message and the reply. */
|
||||
export type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
|
||||
|
||||
const ThemePreferenceSchema = z.enum([
|
||||
...THEME_OPTIONS.map((option) => option.name),
|
||||
@@ -100,7 +101,7 @@ export interface AppSettings {
|
||||
/** How usage reads and which windows the sidebar summary shows. */
|
||||
usage: UsagePreferences;
|
||||
autoExpandReasoning: boolean;
|
||||
toolCallDetailLevel: ToolCallDetailLevel;
|
||||
chatDisplay: ChatDisplay;
|
||||
chatOutlineEnabled: boolean;
|
||||
vimKeybindings: boolean;
|
||||
filePanelAssociations: Record<string, string>;
|
||||
@@ -156,7 +157,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sidebarFooterItems: [],
|
||||
usage: DEFAULT_USAGE_PREFERENCES,
|
||||
autoExpandReasoning: false,
|
||||
toolCallDetailLevel: "detailed",
|
||||
chatDisplay: "collapseTurns",
|
||||
chatOutlineEnabled: true,
|
||||
vimKeybindings: false,
|
||||
filePanelAssociations: {},
|
||||
@@ -199,6 +200,7 @@ const SidebarRowItemsSchema = z
|
||||
type StoredAppSettingsFallback = AppSettings & {
|
||||
uiFontSize?: number;
|
||||
compactToolCalls?: boolean;
|
||||
toolCallDetailLevel?: "overview" | "detailed";
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
needsWrite: boolean;
|
||||
@@ -251,11 +253,13 @@ const StoredAppSettingsSchema = z
|
||||
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
usage: UsagePreferencesSchema,
|
||||
autoExpandReasoning: z.boolean().catch(false),
|
||||
chatDisplay: z.enum(["full", "collapseToolCalls", "collapseTurns"]).optional().catch("full"),
|
||||
// COMPAT(toolCallDetailLevel): replaced by chatDisplay in v0.13, remove after 2027-04-24.
|
||||
toolCallDetailLevel: z
|
||||
.enum(["overview", "detailed"])
|
||||
.or(z.literal("concise").transform(() => "overview" as const))
|
||||
.optional()
|
||||
.catch("detailed"),
|
||||
.catch(undefined),
|
||||
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
|
||||
compactToolCalls: z.boolean().optional().catch(undefined),
|
||||
chatOutlineEnabled: z.boolean().catch(true),
|
||||
@@ -305,8 +309,10 @@ const StoredAppSettingsSchema = z
|
||||
(isChecksHiddenByLegacyRowItem(stored.sidebarRowItems)
|
||||
? "none"
|
||||
: DEFAULT_SIDEBAR_CHECKS_DISPLAY);
|
||||
const toolCallDetailLevel =
|
||||
stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed");
|
||||
const groupsToolCalls = stored.toolCallDetailLevel
|
||||
? stored.toolCallDetailLevel === "overview"
|
||||
: stored.compactToolCalls === true;
|
||||
const chatDisplay = stored.chatDisplay ?? (groupsToolCalls ? "collapseToolCalls" : "full");
|
||||
return {
|
||||
...stored,
|
||||
openInSidePane,
|
||||
@@ -321,7 +327,7 @@ const StoredAppSettingsSchema = z
|
||||
stored.sidebarRowItems.services ??
|
||||
(stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services),
|
||||
},
|
||||
toolCallDetailLevel,
|
||||
chatDisplay,
|
||||
needsWrite,
|
||||
};
|
||||
})
|
||||
@@ -449,6 +455,7 @@ export function normalizeAppSettings(value: unknown): AppSettings {
|
||||
manageBuiltInDaemon: _manageBuiltInDaemon,
|
||||
releaseChannel: _releaseChannel,
|
||||
compactToolCalls: _compactToolCalls,
|
||||
toolCallDetailLevel: _toolCallDetailLevel,
|
||||
uiFontSize: _uiFontSize,
|
||||
...settings
|
||||
} = StoredAppSettingsSchema.parse(value);
|
||||
|
||||
@@ -144,7 +144,10 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes");
|
||||
const workspaceSortSupported = [...sortSupport.values()].every(Boolean);
|
||||
const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined;
|
||||
const sort =
|
||||
groupMode === "project" && (workspaceSort === "manual" || workspaceSortSupported)
|
||||
? workspaceSort
|
||||
: undefined;
|
||||
const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort);
|
||||
|
||||
const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []);
|
||||
@@ -166,12 +169,16 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const updates = computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder,
|
||||
getWorkspaceOrder: sort === "manual" ? orderStore.getWorkspaceOrder : undefined,
|
||||
});
|
||||
|
||||
if (updates.projectOrder) {
|
||||
orderStore.setProjectOrder(updates.projectOrder);
|
||||
}
|
||||
}, [persistedProjectOrder, projects]);
|
||||
for (const update of updates.workspaceOrders) {
|
||||
orderStore.setWorkspaceOrder(update.projectViewKey, update.order);
|
||||
}
|
||||
}, [persistedProjectOrder, projects, sort]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!isActive) return;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
@@ -81,7 +82,13 @@ export function useHostChooser() {
|
||||
);
|
||||
|
||||
if (availableHosts.length === 0) {
|
||||
(input.onNoHosts ?? (() => router.push(buildSettingsAddHostRoute(Date.now()))))();
|
||||
(
|
||||
input.onNoHosts ??
|
||||
(() => {
|
||||
usePanelStore.getState().showMobileAgent();
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
})
|
||||
)();
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1169,6 +1169,7 @@ export const ar: TranslationResources = {
|
||||
sort: {
|
||||
label: "الترتيب",
|
||||
created: "تاريخ الإنشاء",
|
||||
manual: "يدوي",
|
||||
lastPrompt: "آخر طلب",
|
||||
updateHost: "حدّث المضيفين لترتيب مساحات العمل",
|
||||
},
|
||||
@@ -2007,6 +2008,12 @@ export const ar: TranslationResources = {
|
||||
input: "مدخل",
|
||||
output: "الإخراج",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "كتب {{count}} رسالة",
|
||||
other: "كتب {{count}} رسائل",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "حرّر {{count}} ملفًا",
|
||||
@@ -2311,12 +2318,13 @@ export const ar: TranslationResources = {
|
||||
label: "عرض التفكير دائماً",
|
||||
description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "عرض استدعاءات الأدوات",
|
||||
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
|
||||
chatDisplay: {
|
||||
label: "عرض المحادثة",
|
||||
description: "مقدار عمل الوكيل الذي تعرضه المحادثة",
|
||||
options: {
|
||||
overview: "ملخص",
|
||||
detailed: "التفاصيل الكاملة",
|
||||
full: "كامل",
|
||||
collapseToolCalls: "طيّ استدعاءات الأدوات",
|
||||
collapseTurns: "طيّ الأدوار",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1177,6 +1177,7 @@ export const en = {
|
||||
sort: {
|
||||
label: "Sort",
|
||||
created: "Created",
|
||||
manual: "Manual",
|
||||
lastPrompt: "Last prompt",
|
||||
updateHost: "Update hosts to sort workspaces",
|
||||
},
|
||||
@@ -2032,6 +2033,12 @@ export const en = {
|
||||
input: "Input",
|
||||
output: "Output",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "wrote {{count}} message",
|
||||
other: "wrote {{count}} messages",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "edited {{count}} file",
|
||||
@@ -2399,12 +2406,13 @@ export const en = {
|
||||
label: "Always expand reasoning",
|
||||
description: "Show agent thinking and chain-of-thought blocks fully expanded by default",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Tool call display",
|
||||
description: "How tool calls appear in the timeline",
|
||||
chatDisplay: {
|
||||
label: "Chat display",
|
||||
description: "How much of an agent's work the chat shows",
|
||||
options: {
|
||||
overview: "Summary",
|
||||
detailed: "Full detail",
|
||||
full: "Full",
|
||||
collapseToolCalls: "Collapse tool calls",
|
||||
collapseTurns: "Collapse turns",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1205,6 +1205,7 @@ export const es: TranslationResources = {
|
||||
sort: {
|
||||
label: "Orden",
|
||||
created: "Creación",
|
||||
manual: "Manual",
|
||||
lastPrompt: "Último prompt",
|
||||
updateHost: "Actualiza los hosts para ordenar los espacios de trabajo",
|
||||
},
|
||||
@@ -2055,6 +2056,12 @@ export const es: TranslationResources = {
|
||||
input: "Aporte",
|
||||
output: "Producción",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "escribió {{count}} mensaje",
|
||||
other: "escribió {{count}} mensajes",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "editó {{count}} archivo",
|
||||
@@ -2327,12 +2334,13 @@ export const es: TranslationResources = {
|
||||
description:
|
||||
"Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Visualización de llamadas a herramientas",
|
||||
description: "Cómo aparecen las llamadas a herramientas en la cronología",
|
||||
chatDisplay: {
|
||||
label: "Visualización del chat",
|
||||
description: "Cuánto del trabajo del agente muestra el chat",
|
||||
options: {
|
||||
overview: "Resumen",
|
||||
detailed: "Detalle completo",
|
||||
full: "Completo",
|
||||
collapseToolCalls: "Contraer llamadas a herramientas",
|
||||
collapseTurns: "Contraer turnos",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1198,6 +1198,7 @@ export const fr: TranslationResources = {
|
||||
sort: {
|
||||
label: "Tri",
|
||||
created: "Création",
|
||||
manual: "Manuel",
|
||||
lastPrompt: "Dernier prompt",
|
||||
updateHost: "Mettez les hôtes à jour pour trier les espaces de travail",
|
||||
},
|
||||
@@ -2054,6 +2055,12 @@ export const fr: TranslationResources = {
|
||||
input: "Entrée",
|
||||
output: "Sortie",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "{{count}} message écrit",
|
||||
other: "{{count}} messages écrits",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "{{count}} fichier modifié",
|
||||
@@ -2325,12 +2332,13 @@ export const fr: TranslationResources = {
|
||||
label: "Toujours afficher le raisonnement",
|
||||
description: "Afficher par défaut le raisonnement de l’agent entièrement déplié",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Affichage des appels d’outils",
|
||||
description: "Comment les appels d’outils apparaissent dans la chronologie",
|
||||
chatDisplay: {
|
||||
label: "Affichage du chat",
|
||||
description: "La part du travail de l’agent affichée dans le chat",
|
||||
options: {
|
||||
overview: "Résumé",
|
||||
detailed: "Détails complets",
|
||||
full: "Complet",
|
||||
collapseToolCalls: "Réduire les appels d’outils",
|
||||
collapseTurns: "Réduire les tours",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user