diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml
index 833820ff2d..87fc924b6b 100644
--- a/.github/ci-paths.yml
+++ b/.github/ci-paths.yml
@@ -41,6 +41,7 @@ server:
desktop:
- "packages/desktop/**"
- "packages/app/src/desktop/**"
+ - "packages/app/src/**/*.electron.{ts,tsx}"
- "packages/server/src/server/browser-tools/**"
- "packages/app/e2e/support/**"
- "packages/app/*config.{cjs,js,ts}"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7c2a397170..e632cb4b96 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,8 +3,8 @@ name: CI
on:
push:
branches: [main]
+ # Every base branch, so stacked PRs and PRs into `next` get the same checks.
pull_request:
- branches: [main]
merge_group:
workflow_dispatch:
@@ -417,6 +417,14 @@ jobs:
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+
+ - name: Run Android module tests
+ run: npm run test:android --workspace=@getpaseo/app
+
playwright-1:
name: playwright (shard 1/8)
needs: changes
diff --git a/.github/workflows/desktop-packages.yml b/.github/workflows/desktop-packages.yml
index c30eea7da4..77e2735c6a 100644
--- a/.github/workflows/desktop-packages.yml
+++ b/.github/workflows/desktop-packages.yml
@@ -6,7 +6,6 @@ on:
# Packaging leaves ordinary PR CI, but a PR that changes what gets packaged
# or how it is smoked still proves the real artifacts before merge.
pull_request:
- branches: [main]
paths:
- "packages/desktop/**"
- "plugins/**"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index af0f7904ba..7d48d9c289 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,97 @@
# Changelog
+## 0.13.0 - 2026-10-24
+
+Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
+
+Connect your daemon to another machine, then ask an agent to delegate work there:
+
+```sh
+paseo host add 192.168.1.20:6767 --label studio
+paseo host add 'ssh://me@build-box' --label build-box
+paseo host add 'relay://...' --label laptop
+```
+
+### Added
+
+- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
+- Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
+- Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
+- Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576))
+- Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453))
+- Added pasting images and dropping files into terminals, so agent TUIs such as Claude Code and Codex can read them, including on remote hosts ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
+
+### Improved
+
+- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
+- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635))
+- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690))
+- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611))
+- Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580))
+- Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424))
+- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
+- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
+- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693))
+- Updated pinned ACP agents to their latest releases ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
+- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639))
+- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
+- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
+- Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
+- Paused status ring animations inside hidden panels on iOS and Android ([#7423](https://github.com/getpaseo/paseo/pull/7423) by [@peterkeen](https://github.com/peterkeen))
+- Reduced app startup work by loading only the selected language's translations ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8))
+- Removed repeated workspace layout saves when nothing changed ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8))
+
+### Plugins
+
+- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614))
+- Added `useTabState` for per-tab panel state that survives reloads, and `addAgentPanel`; workspace, agent and file panels share the `workspaceId`, `params`, `revision` and `tab` props ([#7685](https://github.com/getpaseo/paseo/pull/7685))
+- Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478))
+- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373))
+- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579))
+- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368))
+- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
+- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
+- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
+- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630))
+- Fixed reopened ACP plugin agents showing an empty chat ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
+- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
+
+### Fixed
+
+- Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573))
+- Fixed parent agents never hearing from `notifyOnFinish` children after a daemon restart ([#7683](https://github.com/getpaseo/paseo/pull/7683))
+- Fixed opening a large Claude session showing an empty chat or crashing the daemon ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
+- Fixed Import session crashing the daemon with large Claude histories ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
+- Fixed hosts staying in Error when the direct connection drops instead of switching to a saved relay connection ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
+- Fixed tapping a notification opening a stale workspace on iOS, and Android reopening the notification's agent on every app reopen ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
+- Fixed sidebar PR status and checks disappearing after the app reconnects ([#7638](https://github.com/getpaseo/paseo/pull/7638))
+- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681))
+- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
+- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608))
+- Fixed a new workspace's pin or title disappearing from the sidebar until the next update ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
+- Fixed Claude background command notifications appearing twice after a daemon restart ([#7737](https://github.com/getpaseo/paseo/pull/7737))
+- Fixed voice replies skipping later sentences with OpenAI-compatible speech servers ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
+- Fixed Cursor usage showing bonus spend as overage instead of the quota percentages ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
+- Fixed the Android tablet taskbar covering the sidebar's Add project, Hosts, Help and Settings ([#7768](https://github.com/getpaseo/paseo/pull/7768))
+- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688))
+- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
+- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
+- Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538))
+- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
+- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589))
+- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589))
+- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
+- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362))
+- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
+- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365))
+- Fixed `paseo agent archive` reporting `AGENT_NOT_FOUND` for agents beyond the first 200 ([#7535](https://github.com/getpaseo/paseo/pull/7535) by [@L4XB](https://github.com/L4XB), [@hteo1337](https://github.com/hteo1337))
+- Fixed archived agents reappearing in MCP `list_agents` after their history was read ([#7532](https://github.com/getpaseo/paseo/pull/7532) by [@thomasvan](https://github.com/thomasvan))
+- Fixed detached agents created with `notifyOnFinish` never notifying their creator ([#7528](https://github.com/getpaseo/paseo/pull/7528) by [@thomasvan](https://github.com/thomasvan))
+- Fixed OpenCode agents being told `notifyOnFinish` defaults to off ([#7383](https://github.com/getpaseo/paseo/pull/7383) by [@gszep](https://github.com/gszep))
+- Fixed Pi subagent rows staying running when a resume is refused ([#7369](https://github.com/getpaseo/paseo/pull/7369))
+- Fixed Android source builds downloading a second NDK ([#7403](https://github.com/getpaseo/paseo/pull/7403) by [@dwyanewang](https://github.com/dwyanewang))
+- Fixed Nix packages reporting the AGPL license instead of Apache-2.0 ([#7415](https://github.com/getpaseo/paseo/pull/7415))
+
## 0.12.0 - 2026-10-19
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md
index 92d3423036..09080be3bc 100644
--- a/CHANGELOG.zh-CN.md
+++ b/CHANGELOG.zh-CN.md
@@ -1,5 +1,97 @@
# 更新日志
+## 0.13.0 - 2026-10-24
+
+折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
+
+将你的 daemon 连接到另一台机器,然后让 Agent 把工作委派过去:
+
+```sh
+paseo host add 192.168.1.20:6767 --label studio
+paseo host add 'ssh://me@build-box' --label build-box
+paseo host add 'relay://...' --label laptop
+```
+
+### 新增
+
+- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
+- 新增已连接主机,Agent 可以在其他机器上创建、提示和监控 Agent,并在 Subagents 中查看它们 ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
+- 新增简体中文版 paseo.sh,地址为 [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
+- Changes 窗格新增 All changes 并设为默认,在一个 diff 中显示自分支基点以来已提交和未提交的工作 ([#7576](https://github.com/getpaseo/paseo/pull/7576))
+- 如果进入 Plan 前开启了 Auto,Claude 计划审批新增 Implement with Auto mode ([#7453](https://github.com/getpaseo/paseo/pull/7453))
+- 新增在终端中粘贴图片和拖放文件,Claude Code、Codex 等 Agent TUI 可以读取它们,远程主机同样适用 ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
+
+### 改进
+
+- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
+- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
+- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690))
+- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611))
+- 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580))
+- Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
+- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
+- 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
+- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693))
+- 已固定版本的 ACP Agent 更新到最新版本 ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
+- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639))
+- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
+- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
+- 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
+- iOS 和 Android 上隐藏面板中的状态环动画现在会暂停 ([#7423](https://github.com/getpaseo/paseo/pull/7423) by [@peterkeen](https://github.com/peterkeen))
+- 应用启动时只加载所选语言的翻译,减少了启动工作量 ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8))
+- 工作区布局未变化时不再重复保存 ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8))
+
+### 插件
+
+- 新增[文件面板](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`;原入口(包括 `useRevealedText`)仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
+- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
+- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
+- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630))
+- 修复了重新打开的 ACP 插件 Agent 聊天为空的问题 ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
+- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
+
+### 修复
+
+- 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573))
+- 修复了 daemon 重启后父 Agent 收不到 `notifyOnFinish` 子 Agent 通知的问题 ([#7683](https://github.com/getpaseo/paseo/pull/7683))
+- 修复了打开大型 Claude 会话时聊天为空或 daemon 崩溃的问题 ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
+- 修复了 Claude 历史记录较大时导入会话导致 daemon 崩溃的问题 ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
+- 修复了直连断开后主机停留在错误状态、不切换到已保存的 relay 连接的问题 ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
+- 修复了 iOS 上点击通知打开过期工作区,以及 Android 每次重新打开应用都会再次打开通知对应 Agent 的问题 ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
+- 修复了应用重新连接后侧边栏中的 PR 状态和检查消失的问题 ([#7638](https://github.com/getpaseo/paseo/pull/7638))
+- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681))
+- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
+- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608))
+- 修复了新工作区的置顶或标题在下次更新前从侧边栏消失的问题 ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
+- 修复了 daemon 重启后 Claude 后台命令通知出现两次的问题 ([#7737](https://github.com/getpaseo/paseo/pull/7737))
+- 修复了使用 OpenAI 兼容语音服务时语音回复跳过后续句子的问题 ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
+- 修复了 Cursor 用量将赠送额度消费显示为超额、而不是配额百分比的问题 ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
+- 修复了 Android 平板任务栏遮挡侧边栏中添加项目、主机、帮助和设置的问题 ([#7768](https://github.com/getpaseo/paseo/pull/7768))
+- 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688))
+- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
+- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547))
+- 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538))
+- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
+- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
+- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
+- 修复了 Android 上不受信任的证书显示 "Host not found"、无法解析的主机名显示 TLS 建议的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
+- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362))
+- 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
+- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365))
+- 修复了 `paseo agent archive` 对前 200 个之后的 Agent 报告 `AGENT_NOT_FOUND` 的问题 ([#7535](https://github.com/getpaseo/paseo/pull/7535) by [@L4XB](https://github.com/L4XB), [@hteo1337](https://github.com/hteo1337))
+- 修复了读取历史记录后已归档的 Agent 重新出现在 MCP `list_agents` 中的问题 ([#7532](https://github.com/getpaseo/paseo/pull/7532) by [@thomasvan](https://github.com/thomasvan))
+- 修复了使用 `notifyOnFinish` 创建的分离 Agent 从不通知其创建者的问题 ([#7528](https://github.com/getpaseo/paseo/pull/7528) by [@thomasvan](https://github.com/thomasvan))
+- 修复了 OpenCode Agent 被告知 `notifyOnFinish` 默认关闭的问题 ([#7383](https://github.com/getpaseo/paseo/pull/7383) by [@gszep](https://github.com/gszep))
+- 修复了恢复被拒绝时 Pi 子 Agent 行仍显示为运行中的问题 ([#7369](https://github.com/getpaseo/paseo/pull/7369))
+- 修复了 Android 源码构建下载第二个 NDK 的问题 ([#7403](https://github.com/getpaseo/paseo/pull/7403) by [@dwyanewang](https://github.com/dwyanewang))
+- 修复了 Nix 包将许可证报告为 AGPL 而不是 Apache-2.0 的问题 ([#7415](https://github.com/getpaseo/paseo/pull/7415))
+
## 0.12.0 - 2026-10-19
后台工作区、新的 Markdown 渲染器(手机上可跨段落选择文本)、重新设计的手机布局,以及超过 120 项修复。
diff --git a/CLAUDE.md b/CLAUDE.md
index 1fb575614d..3f1731597c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -36,6 +36,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
+| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
@@ -43,10 +44,12 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
| [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
+| [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) | Relay E2EE modes: Noise NK mapping, record framing, negotiation, the `requireProtectedSecurity` flag, close codes |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
+| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
@@ -107,6 +110,8 @@ npm run format:check # Check formatting without writing
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
+When Mo requests an APK, use `npm run build:apk:arm64` for an ARM64 build: writes `artifacts/paseo-dogfood-arm64.apk` with diagnostics without starting a device; `npm run build:apk` builds all four Android architectures.
+
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Release branches
diff --git a/SECURITY.md b/SECURITY.md
index ea23984fa0..e17118459e 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -21,8 +21,8 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
-3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
-4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds.
+3. When the phone connects via the relay, it generates fresh ephemeral keys and sends an `e2ee_hello` message. The daemon will not process any application messages until this handshake completes.
+4. Updated peers run a Noise `NK` handshake (`Noise_NK_25519_ChaChaPoly_SHA256`) keyed to the daemon's static key: the `e2ee_hello` carries the first Noise message and the daemon's `e2ee_ready` carries the second. Every later frame is a counter-nonced ChaCha20-Poly1305 record. When either side predates this, the session falls back to the legacy construction: a Curve25519 ECDH shared key with XSalsa20-Poly1305 (NaCl `box`) and a random `[24-byte nonce][ciphertext]` bundle. Both modes negotiate `binaryCiphertext`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) has the exact mapping.
The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.
@@ -34,15 +34,15 @@ The daemon requires a valid cryptographic handshake before processing any comman
- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
-- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
-- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
-- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
+- **Read your traffic** — After the handshake every message is encrypted: ChaCha20-Poly1305 in protected mode, XSalsa20-Poly1305 (NaCl box) in legacy mode
+- **Forge messages** — Both constructions are authenticated encryption; a tampered message is rejected in either mode
+- **Replay old messages** — In protected mode the daemon contributes a fresh ephemeral key to every session and every record carries a strictly increasing counter, so a recorded session cannot be replayed into a new one and a duplicated, reordered, or reflected record closes the session. Legacy mode has neither property: it uses random nonces, and a daemon that still admits legacy clients accepts a recorded legacy transcript (hello, credentials, commands) as a new session. Set `daemon.relay.requireProtectedSecurity: true` to refuse legacy clients once every client you use has updated.
### Trust model
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
diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index 378a0070ea..535a6dd712 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -71,9 +71,39 @@ normal root agent immediately, and closing its still-open tab archives it.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
+Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
+
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
-`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
+`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` for explicit reporting or no completion callback.
+
+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 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.
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
diff --git a/docs/android.md b/docs/android.md
index c90a27efec..5a59eedaa4 100644
--- a/docs/android.md
+++ b/docs/android.md
@@ -61,39 +61,81 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install
+The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
+It backports
+[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
+font size or another unhandled configuration can otherwise disable image selection
+until the app process restarts. Rebuild the native app after changing this patch;
+a Metro reload cannot update it. Remove the patch when the installed
+`expo-modules-core` includes the fix.
+
+Run its native regression tests from `packages/app/android` after prebuild:
+
+```bash
+./gradlew :expo-modules-core:testDebugUnitTest \
+ --tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest \
+ --no-daemon --max-workers=1 -Dorg.gradle.parallel=false
+```
+
From repo root:
```bash
-npm run android:development # Debug build
-npm run android:production # Release build
-npm run android:clear # Remove generated Android project
+npm run build:apk # Universal dogfood APK: 32/64-bit ARM and x86
+npm run build:apk:arm64 # Smaller dogfood APK for ARM64 phones
+npm run dev:android # Build/install the debug app and start development
+npm run dev:ios # Build/install the iOS development app
```
-For a production-ID release APK that local Android profiling tools can attach to:
+`build:apk` writes `artifacts/paseo-dogfood-universal.apk`; `build:apk:arm64` writes
+`artifacts/paseo-dogfood-arm64.apk`. Neither command launches or installs on a device. Both enable
+`PASEO_WATCHDOG_BUILD=1`, the production React profiling renderer, and Android's shell-profileable
+flag. These builds keep the production package ID (`sh.paseo`) and release optimizations. Use this APK for
+personal dogfooding. Android system-trace spans require the separate `PASEO_PROFILE_BUILD=1` flag;
+watchdog recording does not send duplicate spans to that sink by default. Normal store/release
+workflows leave diagnostics disabled.
+
+The older `android`, `android:development`, and `ios` commands remain aliases for development.
+`android:production` still builds and installs a release through Expo; use `build:apk` when you
+need an artifact. `android:clear` removes the generated Android project.
+
+Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain.
+
+### Diagnostic logs without ADB
+
+In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
+retained `.jsonl` files. No app screen, storage permission, or attached debugger is required.
+The recorder retains eight files of about 5 MiB each across reconnects and app restarts. Rotation
+replaces the oldest files, so collect them soon after reproducing a freeze. Uninstall/reinstall
+can leave older Downloads files that the new installation cannot manage.
+
+Each file identifies its build and session. Events include application-transport frame sizes and
+message/request IDs, connection transitions, parse/validation/dispatch spans, named React subtree
+render timings, and foreground JS heartbeat gaps. Payloads, passwords, and message contents
+are excluded; connection identifiers include host addresses. Each size has a unit: **bytes** when
+already available, otherwise **UTF-16 code units** for text. These are
+client transport observations, after relay decryption where applicable, not packet arrival times.
+
+The native recorder flags a heartbeat overdue by more than 500 ms (250 ms expected interval).
+A heartbeat gap bounds the unresponsive interval; it is not an exact blocked-stack duration.
+Backgrounding suspends detection. `js.stall.started` can be written while JS remains blocked;
+`js.stall.recovered` records the gap when JS resumes. Recovery can appear without a start event
+when JS resumes before the next native sampling tick. Process death leaves earlier files intact,
+but queued/unflushed records can be lost. A missing recovery event alone does not prove a crash.
+
+`react.render.slow` records subtree rendering taking at least 16 ms; one-second summaries include
+faster renders. Nested profiler timings overlap: don't add parent and child times. React's render
+measurements do not measure native layout/paint or the complete commit phase. `recorder.dropped`
+means the bounded queue overflowed; missing events in that interval must not be treated as absence
+of activity. Storage failure stops recording rather than blocking the app.
+
+For an ordinary release APK with system trace markers only:
```bash
PASEO_PROFILE_BUILD=1 npm run android:production
```
-This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds
-`` and enables local Android trace markers for workspace mounts
-and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
-and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`).
-
-Or from `packages/app`:
-
-```bash
-# Debug
-npx cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive
-npx cross-env APP_VARIANT=development expo run:android --variant=debug
-
-# Release
-npx cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive
-npx cross-env APP_VARIANT=production expo run:android --variant=release
-
-# Clear generated Android project
-rm -rf android
-```
+Those markers emit only while a system trace records `sh.paseo` (`perfetto -a sh.paseo ...`);
+they do not produce Downloads files.
## Running on an emulator against a worktree daemon
diff --git a/docs/architecture.md b/docs/architecture.md
index 4ed25ba1c4..29f9c7f817 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -4,6 +4,9 @@ Paseo is a client-server system for monitoring and controlling local AI coding a
Your code never leaves your machine. Paseo is local-first.
+Daemon-owned outbound connections and routed API scope ownership are described in
+[Connected hosts](connected-hosts.md).
+
## System overview
```
@@ -71,6 +74,13 @@ server to capability-aware sessions. It deliberately does not use the broad recu
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
not retain non-Git directories.
+Workspace git and forge runtime data belongs to one daemon-level, directory-keyed store.
+Workspace descriptors, checkout diff subscriptions, and script metadata read the last known
+snapshot from that store, so reconnecting clients receive it before refresh completes.
+Refcounted git watches feed the store through snapshot-updated events; their lifetime controls
+refresh only. Session cleanup and workspace archive retain the data. Removing the final active
+workspace for a directory clears it. The store lives in memory and resets on daemon restart.
+
**Key modules:**
| Module | Responsibility |
@@ -172,9 +182,9 @@ Communicates with the daemon via the same WebSocket protocol as the app.
Enables remote access when the daemon is behind a firewall.
-- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305
+- Updated peers negotiate a Noise `NK` session with per-record counters; peers on either side that predate it fall back to the legacy NaCl `box` construction. [docs/relay-endpoint-security.md](relay-endpoint-security.md) owns the mapping, negotiation, and the `daemon.relay.requireProtectedSecurity` flag
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
-- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
+- Client and daemon channels with identical API (`openClientSecureChannel`, `openDaemonSecureChannel`)
- Pairing via QR code transfers the daemon's public key to the client
- New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md
new file mode 100644
index 0000000000..20c86c1295
--- /dev/null
+++ b/docs/connected-hosts.md
@@ -0,0 +1,104 @@
+# Connected hosts
+
+A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list.
+Connect A to B through `paseo --host A host` or `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable
+`registrationId` created before B is reachable.
+
+## Ownership and authority
+
+Credentials live in A's `hosts.json`, written atomically with mode `0600` on POSIX systems. Discovery returns a safe
+target description. Replacement inputs never come back through a read API. Keep this file private
+when collecting diagnostics or backing up daemon state.
+
+The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry,
+including a user's disabled state. Changed input requires an explicit update. Display labels and
+access can be changed by users with `tunnel.manage`; connection and metadata changes require the
+producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again.
+Disabling retains the opt-out.
+
+Each enabled registration owns one physical daemon link, shared by status monitoring and every
+routed API scope. Each scope owns an isolated virtual session on B. A derives its identity from the
+admitted origin session, producer, and logical SDK scope. B admits virtual sessions under the actual
+physical link principal, then intersects forwarded caller grants with that link's current authority.
+Changing or resuming a parent's grants closes its existing channels; reconnect re-admits demand with
+current authority. A routed API cannot select a second hop, and saving a host grants no reverse access.
+
+A producer that registers a host through a routed API is qualified on B as
+`route:::`, with each component percent-encoded. This namespace belongs to the authenticated link
+principal. An ordinary direct B consumer does not become that producer by supplying a source key.
+The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal.
+Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
+
+## Delegating agents
+
+Agent MCP discovers destination IDs with `list_hosts`. `create_workspace`, `list_workspaces`,
+`create_agent`, `send_agent_prompt`, `get_agent_status`, `get_agent_activity`, and `list_agents`
+accept `serverId`; omission selects the calling daemon. Remote creation requires a
+destination workspace and explicit provider/model. Caller filesystem defaults apply only on the
+calling daemon. Prompting a remote child acknowledges acceptance and uses the existing idle wait for
+finish notifications. Remote agent lists have no caller-directory default; pass `cwd` to filter them.
+
+The parent’s Subagents track shows qualified children across connected hosts. Opening a child uses
+the ordinary conversation on the destination's session. The runtime selects among that host's
+connection candidates, including direct connections and routes discovered through another daemon.
+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 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
+
+The shared Node connector owns the target grammar and each connection attempt, including a fresh
+SSH process. SSH reaches an already running daemon. The shared client connection lifecycle owns
+physical reconnect and keepalive; the registry retains configuration, identity acceptance, and recovery
+status. Unreachable registrations remain saved. Identity is pinned when discovered. Self and duplicate
+registrations remain inspectable; a changed destination requires explicit identity replacement.
+
+Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
+A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
+operations; requesting this feature gives update guidance.
+
+Disposing a routed API releases its observations and session. It does not remove the saved host or
+affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
+through the usual owned-subscription contract. The ordinary client queues new requests while connecting
+within its existing deadline and rejects requests while disconnected. Connection loss clears old
+waiters and queued sends. Submitted requests with no response fail with `DaemonConnectionError`
+(`DAEMON_CONNECTION_LOST`), optional `outcome: "unknown"` and known `serverId`. Inspect the destination
+before retrying a mutation.
+Ordinary non-recoverable mutations are not automatically replayed. Existing acknowledged/idempotent
+creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission
+RPC codes pass through unchanged, and connection/recovery behavior matches direct clients.
+
+Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8
+channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to
+base64 JSON. The existing direct binary opcodes and 100 MiB message limit remain unchanged; routed
+payloads reserve space for the routing header inside that limit. Relay encryption retains its own
+existing frame limit.
+
+Host links cap saved registrations, simultaneous channels, handshake lifetime, frames, physical
+output, and aggregate queued bytes. Capacity checks use the actual escaped outer JSON or binary
+frame size. Awaitable output completes when the physical send completes. An overflowing channel
+closes without closing siblings or the saved link. Routed sessions release resources on disconnect;
+subscription demand is rebuilt by its owning client on reconnect.
+
+See the [SDK reference](../public-docs/sdk/reference.md#clienthosts),
+[CLI reference](../public-docs/cli.md#connected-hosts), and
+[plugin reference](../public-docs/plugins/reference.md#connected-hosts).
diff --git a/docs/data-model.md b/docs/data-model.md
index 1b578bb6d8..8bed344aa1 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -317,7 +317,7 @@ Paseo tools; otherwise the provider policy applies. Provider and global policy a
session is created, resumed, imported, or reloaded, so configuration changes affect the next
session rather than an already-running one.
-`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
+`agents.metadataGeneration.providers` selects the models for daemon-side metadata tasks: commit messages, PR text, branch names, and generated titles. An empty list is Automatic; a non-empty list is Manual and only those models are tried. See [public-docs/metadata-generation.md](../public-docs/metadata-generation.md#how-a-model-is-chosen).
### Git process limits
@@ -353,8 +353,6 @@ Environment variables override `config.json`:
after changing `config.json`. Environment changes require a daemon restart; the launch environment
remains authoritative during reload.
-`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
-
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
Set these to select OpenAI instead of local speech:
diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md
index 9fe6bdfd7b..a1778b47b8 100644
--- a/docs/explorer-sidebar.md
+++ b/docs/explorer-sidebar.md
@@ -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
@@ -83,7 +86,12 @@ literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibili
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
hide their close buttons through the panel manifest; close them from the tab context menu.
-Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock, including Agent and terminal profiles.
+Other tabs reveal the close control on hover. Compact layouts show no inline close on any tab,
+since a narrow X beside the label is easy to hit by accident; the long-press menu closes it.
+Compact tabs also keep their natural width and the rail scrolls as soon as they overflow, while
+pointer layouts shrink tabs toward the clickable minimum first. A rail that scrolls never truncates.
+The + menu opens compatible panels in the dock, including Agent and terminal profiles. Compact
+layouts omit Diff from it because compact Changes shows each file's diff inline.
Web also supports dragging agents and terminals into Explorer.
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
diff --git a/docs/expo-router.md b/docs/expo-router.md
index cc65eb57a7..3bd6a43435 100644
--- a/docs/expo-router.md
+++ b/docs/expo-router.md
@@ -104,17 +104,24 @@ Do not collapse this topology with a catch-all, `getId`, or
## Agent Targets
-Notifications and agent URLs enter the router with different authoritative
-targets.
+Notifications and agent URLs name a host and agent. Both use
+`navigateToAgent()`, which reads the agent's current workspace from the store
+or routes through `/h/[serverId]/agent/[agentId]`. The agent route waits for
+the named host, demands its directory, and waits for workspace and layout
+hydration before opening the tab. Keep the stable agent URL free of a workspace
+hint. [Notifications](notifications.md) owns tap delivery and native lifecycle
+constraints.
-- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
- directly to the workspace with the agent open intent.
-- Agent URLs carry only `serverId` and `agentId`. Route them through
- `/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
- the agent's workspace from the host, and then opens the agent there.
+When a workspace target cannot open yet, `navigateToWorkspace()` records a
+pending open in the layout model under that host and workspace key. The owning
+workspace screen consumes it once after hydration. Archived-workspace recovery
+reads the same pending target, so startup redirects cannot discard it and a
+retained screen cannot consume another workspace's request.
-Both paths converge on `navigateToAgent()`. Do not make notification routing
-guess a workspace, and do not add a workspace to the stable agent URL format.
+Legacy `?open=` links seed this model state at the matched route boundary and
+then lose the query parameter. Internal navigation does not use the query as a
+handoff. Read matched route params locally; global search params let retained
+workspace screens observe requests for other workspaces.
## Params
diff --git a/docs/floating-panels.md b/docs/floating-panels.md
index c183dc6615..f17c59c7c2 100644
--- a/docs/floating-panels.md
+++ b/docs/floating-panels.md
@@ -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:
@@ -282,10 +298,12 @@ left = anchorRect.x - hostRect.x;
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
```
-Do this before adding any platform offset. If anchor and host are both measured
-with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
-Only add a status-bar offset when the render surface is not measured in the same
-coordinate system. See `tooltip.tsx` for that separate case.
+Do this before adding any platform offset. The app draws edge-to-edge on Android, so
+`measureInWindow`, touch `pageY`, and a full-screen `Modal` all share one coordinate
+space that starts at the screen top. Do not add `StatusBar.currentHeight` to an anchor:
+React Native 0.86 stopped subtracting the status bar from window coordinates under
+edge-to-edge, and the old compensation now lands every menu, tooltip, and context menu
+one status bar below its trigger.
## Gotcha 5 — The two-measurement flash
@@ -345,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
@@ -403,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
diff --git a/docs/glossary.md b/docs/glossary.md
index 644c109d44..d2c871f74b 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -26,6 +26,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
+- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md
index 375da30708..917ebf9b53 100644
--- a/docs/mobile-testing.md
+++ b/docs/mobile-testing.md
@@ -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
diff --git a/docs/notifications.md b/docs/notifications.md
new file mode 100644
index 0000000000..0e58ccafae
--- /dev/null
+++ b/docs/notifications.md
@@ -0,0 +1,71 @@
+# Notifications
+
+Use `notification-open` for OS notification taps. It owns delivery, parsing,
+process-local deduplication, startup readiness, and native acknowledgement.
+Keep agent and workspace navigation in their existing navigation modules.
+Import the pure API from `@/notification-open` and the app-shell component from
+`@/notification-open/react`; senders must not load the React routing runtime.
+
+Agent payloads name a host and agent. Ignore their workspace hint when opening:
+the agent can move after a notification is sent. Use `navigateToAgent`, which
+uses the current store record or the agent route to resolve the workspace from
+the host. Terminal payloads name their workspace and use `navigateToWorkspace`.
+
+See [agent targets and deferred opens](expo-router.md#agent-targets) for route
+ownership and workspace recovery. Notification generation, presence, and push
+eligibility remain daemon concerns.
+
+## Native delivery
+
+Keep the router instance at module scope. Android's `singleTask` Activity can
+finish on Back while its JS context remains alive. A React ref is lost when the
+root remounts; process-local handled IDs must survive that remount.
+
+Clear `lastNotificationResponse` after dispatch. Reading a launch response does
+not acknowledge it. Without clearing it, a later Activity launch can reopen the
+old agent.
+
+With expo-notifications 57, Android also replays its pending response list to
+new native listeners. Clearing the last response does not drain that list.
+Deduplication covers listener registration within one JS context. A development
+JS reload creates another context and can replay a cold-launch response; do not
+add persisted handled IDs to compensate. Recheck this limitation when upgrading
+expo-notifications.
+
+iOS drains pending responses when its delegate first handles them. Push data
+read by Expo arrives under `userInfo.body`; simulator payloads must match:
+
+```json
+{
+ "aps": { "alert": { "title": "Agent finished", "body": "Open the agent" }, "sound": "default" },
+ "body": {
+ "serverId": "host",
+ "workspaceId": "workspace",
+ "agentId": "agent",
+ "reason": "finished"
+ }
+}
+```
+
+An Android FCM launch intent needs `google.message_id` and a JSON `body` extra.
+An arbitrary Activity launch with data extras does not exercise Expo's FCM path.
+
+Foreground pushes remain in Notification Center without a banner or sound.
+This preserves notifications sent with stale presence information.
+
+## Startup and verification
+
+Wait for the root navigator, host registry, remembered selection, and startup
+redirects before handing off a tap. A compact agent route must demand the host's
+directory itself; its sidebar can be closed for the entire cold launch.
+
+Verify warm and cold taps, stale and missing workspace hints, two distinct taps,
+and Android Back followed by launcher reopen or a different notification tap. Check the focused tab and every
+workspace's saved layout, since a foreign pinned tab survives navigation.
+Use the daemon's payload shape on both iOS and Android; a browser event alone
+does not test native replay or acknowledgement.
+
+Existing foreign pins belong to the reconciliation fix in
+[#6900](https://github.com/getpaseo/paseo/issues/6900). Desktop main-process window
+selection is tracked separately in
+[#6875](https://github.com/getpaseo/paseo/issues/6875).
diff --git a/docs/permissions.md b/docs/permissions.md
index 93a70d6fb4..e2bf7c971f 100644
--- a/docs/permissions.md
+++ b/docs/permissions.md
@@ -31,6 +31,10 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
+Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement
+requires the producer or `access.manage`. Routed operations retain both caller and link authority,
+see [Connected hosts](connected-hosts.md#ownership-and-authority).
+
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
diff --git a/docs/plugins.md b/docs/plugins.md
index fc000f7b15..2a223bfe85 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -86,6 +86,12 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
never deletes it. The global `pluginsEnabled` switch remains available.
+## Client translations
+
+Plugin runtime translations follow the connected app, so never send a daemon locale through RPCs or add plugin resources to the app catalog. The installation owns the translation engine; native portal bridges must carry it alongside RPC and client-state contexts. Keep host label resolution at presentation time so language changes preserve the installation lifetime.
+
+The author contract is in [runtime translations](../public-docs/plugins/reference.md#runtime-translations).
+
## Built-in plugins
Built-in plugins ship from `plugins//`, with `paseo-plugin.json`, `index.server.ts`,
@@ -560,9 +566,11 @@ one section's order from built-ins, plugin groups, and the section's preference
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
panel, and mounts it with a `PluginFile` handle; the panel reads, shows its own `Loading`, writes,
-and resolves conflicts. The bar only switches panels and shows the dot the panel sets through
-`tab.setModified`. 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:
+and resolves conflicts. The host has no bar and no notion of a panel's views: the Markdown and HTML
+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 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.
@@ -579,11 +587,14 @@ the author API, selection, `DocumentFrame`, and the version requirement. The exa
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/reference.md#header-buttons)
owns the author API and placement rules. Keep presentation policy in this module so another
-placement can reuse behavior without copying registration or action state.
+placement can reuse behavior without copying registration or action state. The header overflow
+and the compact workspace menu share one builder that turns buttons into menu rows and pages; the
+workspace menu reaches it through `usePluginWorkspaceMenu`.
Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state,
-query, and toast providers inside the surface content, including overflow pages from different
-plugins. Providers only around the trigger do not reach those bodies.
+query, and toast providers inside the surface content. A host menu can hold buttons from several
+installations, so each of its rows and pages resolves its own installation's runtime. Providers
+only around the trigger do not reach those bodies.
Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned
`subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon
diff --git a/docs/relay-endpoint-security.md b/docs/relay-endpoint-security.md
new file mode 100644
index 0000000000..0fd6a1899f
--- /dev/null
+++ b/docs/relay-endpoint-security.md
@@ -0,0 +1,88 @@
+# Relay endpoint security
+
+The relay carries daemon sessions between a client and a daemon that cannot reach each other directly. The relay is untrusted, so the two endpoints secure the session themselves. This doc owns the protected construction, its mapping onto Paseo's handshake and record flow, the negotiation rules, and the enforcement flag. The threat model lives in [SECURITY.md](../SECURITY.md); the code is `packages/relay/src/secure-channel.ts`.
+
+Fixes [#359](https://github.com/getpaseo/paseo/issues/359): the legacy construction has no replay protection. A recorded legacy hello plus records replays onto a fresh daemon channel, and duplicated records are delivered twice.
+
+## Two modes
+
+| Mode | Construction | Replay protection |
+| ----------- | ------------------------------------------------ | -------------------------------- |
+| `legacy` | X25519 + NaCl `box`, random nonces | none |
+| `protected` | `Noise_NK_25519_ChaChaPoly_SHA256` (revision 34) | counters, fresh daemon ephemeral |
+
+Two updated peers always negotiate `protected`. An old peer on either side falls to `legacy` while the daemon permits it. Everything in `packages/relay/src/encrypted-channel.ts` is the legacy construction and is unchanged.
+
+The shared entry points are `openClientSecureChannel` and `openDaemonSecureChannel`. Callers get a `SecureChannel` and ask it `securityMode()`; nothing outside the module branches on the construction.
+
+## Noise mapping
+
+- **Pattern:** NK. The daemon's static key is the existing `$PASEO_HOME/daemon-keypair.json` X25519 key that pairing already distributes. There is no second daemon identity.
+- **Prologue:** the ASCII bytes `paseo-relay-e2ee/1`.
+- **Primitives:** `@noble/curves` x25519, `@noble/ciphers` ChaCha20-Poly1305, `@noble/hashes` SHA-256 and HMAC. The state machine in `packages/relay/src/noise/` is our own implementation of the spec and is checked against vendored third-party vectors in `noise/fixtures/`.
+- **Message 1** (`-> e, es`) travels inside the legacy hello: `{ type: "e2ee_hello", key, capabilities, protected: { v: 1, message: } }`. `key` is a separate legacy ephemeral so an old daemon still completes a legacy handshake. The Noise payload is the offered parameters.
+- **Message 2** (`<- e, ee`) travels inside the ready: `{ type: "e2ee_ready", protected: { v: 1, message: } }`. Its payload is the selected parameters. The outer `capabilities` field is ignored in protected mode because it is not authenticated.
+- **Parameters payload:** two bytes, `[0x01 version][flags]`. Flag bit 0 is `binaryCiphertext`. Unknown versions, reserved flag bits, and a selection the client did not offer fail the handshake.
+- **Split:** the initiator sends with the first cipher state and receives with the second. The daemon is the mirror. Nonces are 64-bit little-endian in a 12-byte nonce, reserved synchronously before any await, never wrapped. A receiver advances its counter only after the tag verifies.
+
+Nothing else is bound into the handshake. The outer JSON, the relay URL, and session credentials are not authenticated by Noise. Credentials travel in the application `hello` after message 2 verified the daemon.
+
+## Records
+
+Each application frame becomes one record of one or more Noise transport messages:
+
+```
+chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data
+wire record = (u16 BE ciphertextLength || ciphertext)+
+```
+
+`kind` is `0x00` text or `0x01` binary. `flags` bit 0 marks the first chunk and bit 1 the last. `data` is at most 65513 bytes per chunk, so a chunk never exceeds the Noise message limit. The chunk count is `max(1, ceil(totalLength / 65513))`.
+
+The receiver rejects a record whose metadata disagrees between chunks, whose non-final chunks are not full, whose byte total differs from `totalLength`, or that has zero chunks, trailing bytes, reserved flags, or an invalid kind. Partial plaintext never reaches the application. Transport AD is empty, as the Noise spec writes it; framing authenticity comes from the metadata being inside the ciphertext. `packages/relay/src/protected-records.ts` owns this.
+
+Frame kind is preserved on the wire the same way as legacy: text records are base64 WebSocket text, binary records are WebSocket binary when `binaryCiphertext` was negotiated.
+
+## Retries and failures
+
+- The client resends its hello every second until a ready arrives. The daemon caches the exact hello text and ready text for the channel. An identical hello gets the identical ready and counters keep running. A different hello closes the channel (`1008`, transcript mismatch). A ready after the channel is open is ignored.
+- A fresh daemon channel always generates a fresh `e`, so a recorded hello and its records fail authentication when replayed later, including after a daemon restart.
+- A present but invalid `protected` field fails the handshake. There is no fallback to legacy once protected was attempted.
+- Any record failure (bad tag, duplicate, reordered, reflected, malformed) closes the channel and nothing from that record is delivered. The daemon closes with `1011`; the client closes with `4003`, because a browser `WebSocket.close` accepts only `1000` and `3000`-`4999`.
+- A record header is checked against the bytes actually received before any buffer is allocated for it, so a header claiming gigabytes costs nothing.
+- Keys are cleared on close as a best effort.
+
+## Negotiation and admission
+
+The daemon advertises `server_info.features.relayProtectedSecurity: true` on every session, whatever mode that session negotiated. A relay client that ended up in `legacy` with such a daemon closes with `4002` before the client marks itself connected, restores subscriptions, or flushes queued commands. The check lives in the relay E2EE transport, so it also covers the Node connector; direct connections never see it.
+
+The app pins `minimumSecurityMode: "protected"` on every relay connection to a daemon public key after the first protected handshake with that key, or when the pairing offer carries `minimumSecurityMode`. A pinned client passes `minimumMode: "protected"` to the handshake and refuses a legacy ready before sending credentials. The pin belongs to the daemon identity, not to a relay address: re-importing the same key through any relay keeps it, a different key is a different connection, and connection failures never clear it.
+
+The handshake result is read in one place, `HostRuntimeController` when the host's active client reports connected. Every client reaches that point, whether the controller created it or adopted it from a pairing or startup probe, so no client constructor carries a persistence callback. A probe that negotiated protected but was never adopted pins nothing; the pin lands when the host first goes online.
+
+Within one client process the floor also rises on its own. `DaemonClient` and the Node host connector remember a protected result and require it on every later attempt, so a reconnect after a relay drop cannot be answered in legacy mode even before the pin is persisted.
+
+The daemon refuses a legacy client with close `1008` and reason `protected endpoint security required`, and logs `relay_e2ee_handshake_rejected`. That close code stops at the relay: both relays replace a daemon-side close with their own (the wrangler relay sends `1012 Server disconnected`), so an old client sees an ordinary disconnect and keeps retrying. The only client-detectable failure is the client's own `4002`, which stops reconnecting and tells the user to update.
+
+The client learns its own `4002` and `4003` from the relay E2EE transport, which reports the close the moment the channel decides it. A socket close event arrives later and the channel's error callback earlier; neither is what `DaemonClient` acts on, because acting on the error would start a reconnect before the code is known.
+
+## Enforcement flag
+
+`daemon.relay.requireProtectedSecurity` in `config.json` (also `relay.requireProtectedSecurity` through the config RPC) makes the daemon refuse legacy relay clients. Default `false`. It is a local daemon setting with no relay-server counterpart.
+
+- It is read when each hello arrives, so toggling it affects the next admission without a restart. Sessions already admitted in legacy mode stay up until they disconnect; turning the flag on does not close them. To finish enforcement, restart the daemon or disconnect its relay sessions after enabling the flag: every reconnect is then a new admission and legacy clients are refused.
+- While it is on, pairing offers carry `minimumSecurityMode: "protected"` and `relay://` URIs carry `security=protected`, so new clients pin before their first handshake.
+- Direct and local connections are outside the flag.
+
+Flipping the default is a separate decision, tentatively reviewed about 30 days after the compatibility release. Nothing in the code expires on a date.
+
+## Known limitation
+
+While a daemon accepts legacy clients, a transcript recorded from any legacy session (hello, credentials, commands) can still be replayed to it. Pinning on the client does not change that, because the attacker replays the old client's transcript, not the pinned client's. The only remedy is enabling the flag on that daemon.
+
+## Testing
+
+- `packages/relay/src/noise/noise.test.ts`: vendored vectors, replay and out-of-order rejection at the cipher-state level.
+- `packages/relay/src/protected-records.test.ts`: framing round trips and every malformed-record rule.
+- `packages/relay/src/secure-channel.test.ts`: both modes, mixed versions, policy rejection, retries, recorded-transcript replay, downgrade attempts.
+- `packages/server/src/server/websocket/secure-relay-ingress.test.ts`: daemon admission and the flag.
+- `packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts`: real daemon through the wrangler relay. Set `PASEO_RELAY_E2E_PORT` to run the same cases against an already-running relay such as the Elixir relay in Docker.
diff --git a/docs/release.md b/docs/release.md
index 751998c864..83a419b8ce 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -62,9 +62,10 @@ release. This applies to both beta and stable releases.
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
-**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
-and GitHub permits only squash merges. Enable checks and required-check protection
-for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
+**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
+push runs target only `main`, and GitHub permits only squash merges. Enable
+required-check protection for `next`, CI on its pushes, and merge commits for the
+integration PR. Handle PR
base changes (`edited` events) so retargeting runs checks against the new base;
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
@@ -537,8 +538,8 @@ Older prerelease bodies on GitHub keep the notes they were synced with; `Release
`CHANGELOG.zh-CN.md` holds the Chinese notes for `paseo.sh/zh-CN/changelog`. Only the website reads it; the app's **What's new** sheet, GitHub release bodies and F-Droid read `CHANGELOG.md`.
-- Translations never gate a release. Translate a new entry in a follow-up PR, or in the changelog commit when you can.
-- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice, so a beta rewritten in place drops its old translation until the new notes are translated. Delete Chinese entries whose English entry was renamed.
+- The release agent writes the Chinese entry in the changelog commit, after the English entry is approved. Every beta that rewrites the English entry rewrites the Chinese one too, and promotion renames both. `packages/website/e2e/i18n.spec.ts` fails CI on the release commit when any entry has no current translation.
+- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice. Delete Chinese entries whose English entry was renamed.
- Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
## Changelog ownership
@@ -653,7 +654,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
-- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release
+- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
@@ -676,7 +677,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
-- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release
+- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
diff --git a/fastlane/metadata/android/en-US/changelogs/130001.txt b/fastlane/metadata/android/en-US/changelogs/130001.txt
new file mode 100644
index 0000000000..f28b4c4040
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/130001.txt
@@ -0,0 +1,15 @@
+Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
+
+Connect your daemon to another machine, then ask an agent to delegate work there:
+
+`sh
+
+paseo host add 192.168.1.20:6767 --label studio
+
+paseo host add 'ssh://me@build-box' --label build-box
+
+paseo host add 'relay://...' --label laptop
+
+`
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/130002.txt b/fastlane/metadata/android/en-US/changelogs/130002.txt
new file mode 100644
index 0000000000..f28b4c4040
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/130002.txt
@@ -0,0 +1,15 @@
+Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
+
+Connect your daemon to another machine, then ask an agent to delegate work there:
+
+`sh
+
+paseo host add 192.168.1.20:6767 --label studio
+
+paseo host add 'ssh://me@build-box' --label build-box
+
+paseo host add 'relay://...' --label laptop
+
+`
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/130003.txt b/fastlane/metadata/android/en-US/changelogs/130003.txt
new file mode 100644
index 0000000000..f28b4c4040
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/130003.txt
@@ -0,0 +1,15 @@
+Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
+
+Connect your daemon to another machine, then ask an agent to delegate work there:
+
+`sh
+
+paseo host add 192.168.1.20:6767 --label studio
+
+paseo host add 'ssh://me@build-box' --label build-box
+
+paseo host add 'relay://...' --label laptop
+
+`
+
+Full notes: https://paseo.sh/changelog
diff --git a/fastlane/metadata/android/en-US/changelogs/130004.txt b/fastlane/metadata/android/en-US/changelogs/130004.txt
new file mode 100644
index 0000000000..f28b4c4040
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/130004.txt
@@ -0,0 +1,15 @@
+Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
+
+Connect your daemon to another machine, then ask an agent to delegate work there:
+
+`sh
+
+paseo host add 192.168.1.20:6767 --label studio
+
+paseo host add 'ssh://me@build-box' --label build-box
+
+paseo host add 'relay://...' --label laptop
+
+`
+
+Full notes: https://paseo.sh/changelog
diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash
index 2d9ebc92ff..28e353268d 100644
--- a/nix/npm-deps.hash
+++ b/nix/npm-deps.hash
@@ -1 +1 @@
-sha256-A5l+geXBbMYZS5lUkFA/+bOhQdM9zkmsYP1dh/Z3Yaw=
+sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
diff --git a/package-lock.json b/package-lock.json
index ba33cc2f4f..114a2b59fb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "paseo",
- "version": "0.12.0",
+ "version": "0.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
- "version": "0.12.0",
+ "version": "0.13.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -8805,6 +8805,45 @@
"license": "MIT",
"optional": true
},
+ "node_modules/@noble/ciphers": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
+ "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
+ "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.4.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
+ "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -35656,7 +35695,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
- "version": "0.12.0",
+ "version": "0.13.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37922,12 +37961,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
- "version": "0.12.0",
+ "version": "0.13.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
- "@getpaseo/client": "0.12.0",
- "@getpaseo/protocol": "0.12.0",
- "@getpaseo/server": "0.12.0",
+ "@getpaseo/client": "0.13.0",
+ "@getpaseo/protocol": "0.13.0",
+ "@getpaseo/server": "0.13.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38174,10 +38213,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
- "version": "0.12.0",
+ "version": "0.13.0",
"dependencies": {
- "@getpaseo/protocol": "0.12.0",
- "@getpaseo/relay": "0.12.0",
+ "@getpaseo/protocol": "0.13.0",
+ "@getpaseo/relay": "0.13.0",
+ "ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -38188,7 +38228,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
- "version": "0.12.0",
+ "version": "0.13.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38432,7 +38472,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
- "version": "0.12.0",
+ "version": "0.13.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -42084,7 +42124,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
- "version": "0.12.0",
+ "version": "0.13.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -42113,14 +42153,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
- "version": "0.12.0",
+ "version": "0.13.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
+ "i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
- "@getpaseo/client": "0.12.0",
- "@getpaseo/protocol": "0.12.0",
+ "@getpaseo/client": "0.13.0",
+ "@getpaseo/protocol": "0.13.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -42130,8 +42171,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
- "@getpaseo/client": "0.12.0",
- "@getpaseo/protocol": "0.12.0",
+ "@getpaseo/client": "0.13.0",
+ "@getpaseo/protocol": "0.13.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -42153,7 +42194,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
- "version": "0.12.0",
+ "version": "0.13.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -42180,8 +42221,11 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
- "version": "0.12.0",
+ "version": "0.13.0",
"dependencies": {
+ "@noble/ciphers": "^2.4.0",
+ "@noble/curves": "^2.4.0",
+ "@noble/hashes": "^2.4.0",
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
"ws": "^8.14.2"
@@ -42398,16 +42442,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
- "version": "0.12.0",
+ "version": "0.13.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
- "@getpaseo/client": "0.12.0",
- "@getpaseo/highlight": "0.12.0",
- "@getpaseo/plugin": "0.12.0",
- "@getpaseo/protocol": "0.12.0",
- "@getpaseo/relay": "0.12.0",
+ "@getpaseo/client": "0.13.0",
+ "@getpaseo/highlight": "0.13.0",
+ "@getpaseo/plugin": "0.13.0",
+ "@getpaseo/protocol": "0.13.0",
+ "@getpaseo/relay": "0.13.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -43328,7 +43372,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
- "version": "0.12.0",
+ "version": "0.13.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -44938,7 +44982,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
- "version": "0.12.0",
+ "version": "0.13.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
diff --git a/package.json b/package.json
index 67ca259ffa..97a224ef4a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "paseo",
- "version": "0.12.0",
+ "version": "0.13.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -41,12 +41,16 @@
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
+ "dev:android": "npm run dev:android --workspace=@getpaseo/app",
+ "dev:ios": "npm run dev:ios --workspace=@getpaseo/app",
"test:e2e:mobile": "./scripts/test-mobile-agent-device.sh",
"test:e2e:composer-keyboard:android": "./packages/app/e2e/mobile/composer-keyboard/android.sh",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
"build": "npm run build --workspaces --if-present",
+ "build:apk": "node scripts/build-apk.mjs",
+ "build:apk:arm64": "node scripts/build-apk.mjs arm64",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
"build:plugin": "npm run build --workspace=@getpaseo/plugin",
@@ -80,15 +84,14 @@
"acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift",
"acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update",
"start": "npm run start --workspace=@getpaseo/server",
- "android": "npm run android --workspace=@getpaseo/app",
+ "android": "npm run dev:android",
"android:development": "npm run android:development --workspace=@getpaseo/app",
"android:production": "npm run android:production --workspace=@getpaseo/app",
"android:release": "npm run android:production --workspace=@getpaseo/app",
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
- "android:clean": "npm run android:clean --workspace=@getpaseo/app",
"fdroid:changelogs": "node scripts/sync-fdroid-changelogs.mjs",
"fdroid:changelogs:check": "node scripts/sync-fdroid-changelogs.mjs --check",
- "ios": "npm run ios --workspace=@getpaseo/app",
+ "ios": "npm run dev:ios",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
diff --git a/packages/app/app.config.js b/packages/app/app.config.js
index 17bc6fe636..7ddfc95029 100644
--- a/packages/app/app.config.js
+++ b/packages/app/app.config.js
@@ -10,6 +10,7 @@ const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
+const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1";
const buildProfile = isFdroidBuild
@@ -181,7 +182,7 @@ export default {
},
],
...buildProfile.fdroidPlugins,
- ...(isProfileBuild ? [withAndroidProfileable] : []),
+ ...(isProfileBuild || isWatchdogBuild ? [withAndroidProfileable] : []),
],
experiments: {
typedRoutes: true,
@@ -191,6 +192,9 @@ export default {
extra: {
fdroidBuild: isFdroidBuild,
profileBuild: isProfileBuild,
+ watchdogBuild: isWatchdogBuild,
+ diagnosticRevision: process.env.PASEO_DIAGNOSTIC_REVISION,
+ diagnosticBuildTime: process.env.PASEO_DIAGNOSTIC_BUILD_TIME,
router: {},
eas: {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
diff --git a/packages/app/e2e/browser/add-project-flow.spec.ts b/packages/app/e2e/browser/add-project-flow.spec.ts
index f66b963d5f..5770c3be9d 100644
--- a/packages/app/e2e/browser/add-project-flow.spec.ts
+++ b/packages/app/e2e/browser/add-project-flow.spec.ts
@@ -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();
+ });
+});
diff --git a/packages/app/e2e/browser/agent-message-rewind.spec.ts b/packages/app/e2e/browser/agent-message-rewind.spec.ts
index 4875cc31c1..8d4e773259 100644
--- a/packages/app/e2e/browser/agent-message-rewind.spec.ts
+++ b/packages/app/e2e/browser/agent-message-rewind.spec.ts
@@ -21,8 +21,10 @@ async function completeSubmittedTurn(
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
+ await expectAgentReadyToInterrupt(page);
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
+ await expectAgentIdle(page);
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
return userMessage;
diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts
index 6d8acc05d3..61173613ea 100644
--- a/packages/app/e2e/browser/changes-pane.spec.ts
+++ b/packages/app/e2e/browser/changes-pane.spec.ts
@@ -262,24 +262,74 @@ test.afterEach(async () => {
}
});
-test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
+test("Changes opens All changes for a clean checkout", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace, 90_000);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
- await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
+ await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("All changes");
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
});
-test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
+test("All changes combines committed and pending edits and preserves an unfinished review", async ({
+ page,
+ context,
+}) => {
+ const workspace = await createWorkspaceWithNetDiff();
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+ await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
+ await openWorkspaceChangesSurface(page, workspace);
+ const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
+ const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
+ const mode = tree.getByRole("button", { name: "Diff mode", exact: true });
+ await expect(mode).toContainText("All changes");
+ await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts"]);
+ await expectRenderedAddedLine(page, "committed");
+
+ await test.step("an unfinished review survives the first dirty edit", async () => {
+ await startReviewOnFirstChangedLine(page);
+ await page
+ .getByRole("textbox", { name: "Review comment" })
+ .fill("Unfinished review stays here");
+ await writeFile(path.join(workspace.repoPath, "c-untracked.txt"), "untracked\n");
+ await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]);
+ await expect(mode).toContainText("All changes");
+ await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(
+ "Unfinished review stays here",
+ );
+ await cancelInlineReview(page);
+ });
+ await test.step("pending edits form a live net patch over the committed edit", async () => {
+ await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "working\n");
+ await expectRenderedAddedLine(page, "working");
+ await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("a-tracked.ts, +1, -1");
+ });
+ await test.step("committing the same contents preserves the rendered comparison", async () => {
+ execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
+ execFileSync("git", ["commit", "-m", "Commit pending changes"], { cwd: workspace.repoPath });
+ await expect(page.getByTestId("changes-primary-cta")).toContainText("Merge", {
+ timeout: 30_000,
+ });
+ await expect(mode).toContainText("All changes");
+ await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]);
+ await expectRenderedAddedLine(page, "working");
+ });
+ await test.step("undoing to the comparison base removes the net change", async () => {
+ await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "base\n");
+ await expectNetComparisonFiles(page, ["b-committed.ts", "c-untracked.txt"]);
+ await expect(mode).toContainText("All changes");
+ });
+});
+
+test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const mode = tree.getByTestId("changes-diff-status-trigger");
- await expect(mode).toContainText("Uncommitted");
+ await expect(mode).toContainText("All changes");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
@@ -294,7 +344,23 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
});
await writeFile(path.join(workspace.repoPath, "new-working-change.txt"), "uncommitted\n");
- await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
+ await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible({
+ timeout: 30_000,
+ });
+ await expect(mode).toContainText("Committed");
+
+ await selectChangesComparison(page, "Uncommitted");
+ execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
+ execFileSync("git", ["commit", "-m", "Commit new working change"], { cwd: workspace.repoPath });
+ await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible({
+ timeout: 30_000,
+ });
+ await expect(mode).toContainText("Uncommitted");
+ await writeFile(path.join(workspace.repoPath, "another-working-change.txt"), "pending\n");
+ await expect(tree.getByText("another-working-change.txt", { exact: true })).toBeVisible({
+ timeout: 30_000,
+ });
+ await expect(mode).toContainText("Uncommitted");
});
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
@@ -633,6 +699,7 @@ test("changes context menus expose folder revert and restore a file after confir
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
+ await selectChangesComparison(page, "Uncommitted");
const tree = changesTree(page);
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
@@ -680,6 +747,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
+ await selectChangesComparison(page, "Uncommitted");
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
@@ -710,6 +778,7 @@ test("discarding an untracked file removes it from the working tree", async ({ p
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
+ await selectChangesComparison(page, "Uncommitted");
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
@@ -741,6 +810,7 @@ test("shows a revert error returned by the daemon", async ({ page }) => {
await failNextDiscardRequest(page);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
+ await selectChangesComparison(page, "Uncommitted");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const confirmation = new Promise((resolve) => {
@@ -892,6 +962,8 @@ test("compact Explorer renders inline hunks with a saved tree-only desktop prefe
{ key: CHANGES_PREFERENCES_KEY },
);
const explorer = await openCompactChanges(page, workspace);
+ await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
+ await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible();
await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible();
await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" });
@@ -1525,6 +1597,64 @@ async function createWorkspaceWithMountedTabDiff(
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
}
+async function createWorkspaceWithNetDiff(): Promise {
+ const repo = await createTempGitRepo("changes-net-", {
+ files: [{ path: "a-tracked.ts", content: "base\n" }],
+ });
+ const client = await connectSeedClient();
+ cleanupTasks.push({
+ run: async () => {
+ await client.close();
+ await repo.cleanup();
+ },
+ });
+ execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
+ await writeFile(path.join(repo.path, "a-tracked.ts"), "committed\n");
+ await writeFile(path.join(repo.path, "b-committed.ts"), "committed only\n");
+ execFileSync("git", ["add", "--all"], { cwd: repo.path });
+ execFileSync("git", ["commit", "-m", "Committed changes"], { cwd: repo.path });
+ const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
+ if (!created.workspace) throw new Error(created.error ?? "Failed to create net diff workspace");
+ return { id: created.workspace.id, repoPath: repo.path };
+}
+
+async function expectNetComparisonFiles(page: Page, files: string[]): Promise {
+ const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
+ const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
+ await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText(
+ files,
+ );
+ await expect(panel.getByTestId(/^diff-file-\d+$/)).toHaveCount(files.length);
+ for (const file of files) {
+ await expect(diffHeaderForPath(panel, file)).toBeVisible();
+ }
+}
+
+async function expectRenderedAddedLine(page: Page, contents: string): Promise {
+ await changesTree(page).getByText("a-tracked.ts", { exact: true }).click();
+ await expect(async () => {
+ await page.keyboard.press("Escape");
+ await page.evaluate(() => navigator.clipboard.writeText(""));
+ const body = page.getByTestId("diff-file-0-body");
+ const canvas = page.getByTestId("git-diff-canvas");
+ const bounds = await body.boundingBox();
+ if (!bounds) throw new Error("Expanded diff body has no bounds");
+ const fontSize = await canvas.evaluate((element) =>
+ Number.parseFloat(getComputedStyle(element).fontSize),
+ );
+ // Replacement: hunk header, removed line, added line.
+ await page.mouse.click(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5, {
+ button: "right",
+ });
+ const copyLine = page.getByRole("menuitem", { name: "Copy line", exact: true });
+ await expect(copyLine).toBeEnabled({ timeout: 1000 });
+ await copyLine.click();
+ await expect
+ .poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 1000 })
+ .toBe(contents);
+ }).toPass({ timeout: 15_000 });
+}
+
async function createWorkspaceWithCommittedDiff(): Promise {
const repo = await createTempGitRepo("changes-committed-", {
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
diff --git a/packages/app/e2e/browser/command-center-agent-controls.spec.ts b/packages/app/e2e/browser/command-center-agent-controls.spec.ts
index 658e0e1123..a2082ac09b 100644
--- a/packages/app/e2e/browser/command-center-agent-controls.spec.ts
+++ b/packages/app/e2e/browser/command-center-agent-controls.spec.ts
@@ -5,6 +5,7 @@ import {
applyCommandCenterAgentControls,
chooseCommandCenterAgentControl,
openCommandCenterForAgent,
+ searchAgentControls,
expectCommandCenterAgentControlSelected,
expectFocusedAgentControls,
expectWorkspaceAgentConfiguration,
@@ -111,8 +112,8 @@ test.describe("Command Center agent controls", () => {
});
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
- const input = await openCommandCenterForAgent(page, workspace);
- await input.fill("load");
+ await openCommandCenterForAgent(page, workspace);
+ await searchAgentControls(page, "load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
@@ -184,7 +185,7 @@ test.describe("Command Center agent controls", () => {
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
- await input.fill("loa");
+ await searchAgentControls(page, "loa");
// Moves the highlight onto a model row, which still matches after the next keystroke.
await page.keyboard.press("ArrowDown");
await input.fill("load");
diff --git a/packages/app/e2e/browser/command-center-file-search.spec.ts b/packages/app/e2e/browser/command-center-file-search.spec.ts
index 5964dcc978..2f16db6f09 100644
--- a/packages/app/e2e/browser/command-center-file-search.spec.ts
+++ b/packages/app/e2e/browser/command-center-file-search.spec.ts
@@ -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();
+ }
+ });
+});
diff --git a/packages/app/e2e/browser/command-center-scroll.spec.ts b/packages/app/e2e/browser/command-center-scroll.spec.ts
index f6762bb4cc..0a66b59856 100644
--- a/packages/app/e2e/browser/command-center-scroll.spec.ts
+++ b/packages/app/e2e/browser/command-center-scroll.spec.ts
@@ -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();
}
diff --git a/packages/app/e2e/browser/command-center-workspaces.spec.ts b/packages/app/e2e/browser/command-center-workspaces.spec.ts
index 4b01b0c67d..38b2e33cff 100644
--- a/packages/app/e2e/browser/command-center-workspaces.spec.ts
+++ b/packages/app/e2e/browser/command-center-workspaces.spec.ts
@@ -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();
+ }
+ });
+});
diff --git a/packages/app/e2e/browser/developer-ui.spec.ts b/packages/app/e2e/browser/developer-ui.spec.ts
new file mode 100644
index 0000000000..c835d2a952
--- /dev/null
+++ b/packages/app/e2e/browser/developer-ui.spec.ts
@@ -0,0 +1,58 @@
+import { metroTest as test, expect } from "../support/fixtures";
+import { hideDeveloperToasts } from "../support/developer-ui.mjs";
+import type { Page } from "@playwright/test";
+
+async function showTransientDeveloperToast(page: Page, shadow: boolean) {
+ await page.evaluate((useShadow) => {
+ const toast = document.createElement("div");
+ toast.id = "error-toast";
+ document.body.append(toast);
+ const root = useShadow ? toast.attachShadow({ mode: "open" }) : toast;
+ const button = document.createElement("button");
+ button.style.cssText = "width:32px;height:32px";
+ button.addEventListener("click", () => button.remove());
+ root.append(button);
+ }, shadow);
+}
+
+async function openHiddenDeveloperToast(page: Page) {
+ await page.setContent("");
+ await showTransientDeveloperToast(page, false);
+ await hideDeveloperToasts(page);
+}
+
+async function navigateWithHiddenDeveloperToasts(page: Page) {
+ await hideDeveloperToasts(page);
+ await page.goto("data:text/html,");
+ await showTransientDeveloperToast(page, true);
+}
+
+async function inspectLayoutWithHiddenToast(page: Page) {
+ const action = page.getByRole("button", { name: "Continue", exact: true });
+ const boxes = await Promise.all(Array.from({ length: 4 }, () => action.boundingBox()));
+ for (const box of boxes) expect(box).not.toBeNull();
+ await expect(page.locator("#error-toast")).toBeHidden();
+ await action.click();
+}
+
+test("a hidden developer toast does not stall concurrent layout checks", async ({ page }) => {
+ test.setTimeout(10000);
+ await openHiddenDeveloperToast(page);
+ await inspectLayoutWithHiddenToast(page);
+});
+
+test("developer toast hiding survives navigation and Expo shadow-root updates", async ({
+ page,
+}) => {
+ test.setTimeout(10000);
+ await navigateWithHiddenDeveloperToasts(page);
+ await inspectLayoutWithHiddenToast(page);
+ await page.evaluate(() => {
+ const root = document.getElementById("error-toast")!.shadowRoot!;
+ const replacement = document.createElement("button");
+ replacement.style.cssText = "width:32px;height:32px";
+ replacement.addEventListener("click", () => replacement.remove());
+ root.append(replacement);
+ });
+ await inspectLayoutWithHiddenToast(page);
+});
diff --git a/packages/app/e2e/browser/file-change-conflicts.spec.ts b/packages/app/e2e/browser/file-change-conflicts.spec.ts
index 1a372af35d..cfc9ea1a55 100644
--- a/packages/app/e2e/browser/file-change-conflicts.spec.ts
+++ b/packages/app/e2e/browser/file-change-conflicts.spec.ts
@@ -182,7 +182,8 @@ test.describe("Workspace file change conflicts", () => {
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await unlink(filePath);
- await expect(filePane(page).getByText("The file was deleted.", { exact: true })).toBeVisible();
+ await expectDeletedFileNotice(page);
+ await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await writeFile(filePath, "# After\n", "utf8");
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts
index f06c244ba3..0266d02691 100644
--- a/packages/app/e2e/browser/file-editing.spec.ts
+++ b/packages/app/e2e/browser/file-editing.spec.ts
@@ -1,5 +1,6 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
+import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import {
expandFolder,
@@ -55,10 +56,6 @@ async function openWorkspaceFile(page: Page, filename: string): Promise {
await expectFileTabOpen(page, filename);
}
-function filePanelSwitcher(page: Page) {
- return page.getByTestId("file-panel-switcher").filter({ visible: true });
-}
-
function htmlPreview(page: Page) {
return {
host: page.getByTestId("file-html-preview"),
@@ -66,13 +63,32 @@ function htmlPreview(page: Page) {
};
}
-/** Switches the file tab to one of its panels from the bar's switcher. */
-async function selectFilePanel(page: Page, title: string): Promise {
- const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true });
+async function selectFileView(page: Page, view: "Preview" | "Source"): Promise {
+ const option = page.getByTestId("file-panel-bar").getByRole("button", {
+ name: view,
+ exact: true,
+ });
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
+async function expectAbove(upper: Locator, lower: Locator): Promise {
+ const [upperBox, lowerBox] = await Promise.all([upper.boundingBox(), lower.boundingBox()]);
+ expect(upperBox).not.toBeNull();
+ expect(lowerBox).not.toBeNull();
+ expect(upperBox!.y + upperBox!.height).toBeLessThanOrEqual(lowerBox!.y);
+}
+
+/** How many times the app has read the file's contents from the daemon. */
+function fileReadCount(
+ gate: Awaited>,
+ fileName: string,
+): number {
+ return gate
+ .getClientRequests("file_explorer_request")
+ .filter((request) => request.mode === "file" && String(request.path).endsWith(fileName)).length;
+}
+
async function expectMarkdownImageLoaded(page: Page, name: string): Promise {
const image = page.getByRole("button", { name, exact: true });
await expect(image).toBeVisible();
@@ -482,9 +498,9 @@ test.describe("CodeMirror workspace file editing", () => {
relativePath,
);
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
- const modeControl = filePanelSwitcher(page);
+ const modeControl = page.getByTestId("file-preview-mode");
await expect(modeControl).toBeVisible();
- await selectFilePanel(page, "Source");
+ await selectFileView(page, "Source");
const editorHost = page.getByTestId("file-source-editor");
const content = editor(page);
@@ -546,7 +562,7 @@ test.describe("CodeMirror workspace file editing", () => {
);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
- await selectFilePanel(page, "Source");
+ await selectFileView(page, "Source");
const markdownScroller = page
.getByTestId("file-source-editor")
@@ -582,6 +598,11 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(page.getByLabel(/File size/)).toBeVisible();
await expect(page.getByLabel(/lines/)).toBeVisible();
+ await expect(page.getByTestId("file-preview-mode")).toHaveCount(0);
+ await expectAbove(
+ page.getByTestId("file-panel-bar").filter({ visible: true }),
+ page.getByTestId("file-source-editor").filter({ visible: true }),
+ );
await replaceEditorText(page, "const autosaved = 2;\n");
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
@@ -678,11 +699,12 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
});
- test("refreshes Markdown and images while switching between Markdown and Source", async ({
+ test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
+ const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
const markdownPath = path.join(workspace.repoPath, "notes.md");
const imagePath = path.join(workspace.repoPath, "pixel.png");
@@ -692,17 +714,30 @@ test.describe("CodeMirror workspace file editing", () => {
await openWorkspaceFile(page, "notes.md");
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
+ const toolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
- await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
+ await expect(toolbar.getByLabel(/^File size/)).toBeVisible();
+ await expect(toolbar.getByLabel("2 lines")).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8");
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
- await selectFilePanel(page, "Source");
+ // Source shows the same read in the editor; the file is not read again.
+ const reads = fileReadCount(gate, "notes.md");
+ await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
+ await expect(editor(page)).toContainText("# Updated heading");
+ await expect(toolbar.getByLabel("Line 1, column 1")).toBeVisible();
+ expect(fileReadCount(gate, "notes.md")).toBe(reads);
+
+ // Preview shows the edits made in Source.
await replaceEditorText(page, "# Saved from source\n");
- await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
- await selectFilePanel(page, "Markdown");
+ await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
+ await selectFileView(page, "Preview");
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
+ await expect(toolbar.getByLabel("Line 1, column 1")).toHaveCount(0);
+ await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
+ await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).not.toBeVisible();
await openWorkspaceFile(page, "pixel.png");
const image = visibleFilePane.locator("img");
@@ -784,10 +819,10 @@ test.describe("CodeMirror workspace file editing", () => {
);
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
- await selectFilePanel(page, "Source");
+ await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(preview.host).toHaveCount(0);
- await selectFilePanel(page, "HTML");
+ await selectFileView(page, "Preview");
await expect(preview.host).toBeVisible();
});
diff --git a/packages/app/e2e/browser/folder-links.spec.ts b/packages/app/e2e/browser/folder-links.spec.ts
index f557d97737..a893fbcb68 100644
--- a/packages/app/e2e/browser/folder-links.spec.ts
+++ b/packages/app/e2e/browser/folder-links.spec.ts
@@ -14,9 +14,8 @@ async function openMarkdownPreview(page: Page) {
await openFileFromExplorer(page, "README.md");
await expectFileTabOpen(page, "README.md");
await page
- .getByTestId("file-panel-switcher")
- .filter({ visible: true })
- .getByRole("button", { name: "Markdown", exact: true })
+ .getByTestId("file-panel-bar")
+ .getByRole("button", { name: "Preview", exact: true })
.click();
await ensureWorkspaceAgentPaneVisible(page);
}
diff --git a/packages/app/e2e/browser/import-session-flow.spec.ts b/packages/app/e2e/browser/import-session-flow.spec.ts
index 788a6f93ec..c6d34f38f2 100644
--- a/packages/app/e2e/browser/import-session-flow.spec.ts
+++ b/packages/app/e2e/browser/import-session-flow.spec.ts
@@ -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");
});
diff --git a/packages/app/e2e/browser/new-workspace-entry.spec.ts b/packages/app/e2e/browser/new-workspace-entry.spec.ts
index e6c788a8f4..3f1710b918 100644
--- a/packages/app/e2e/browser/new-workspace-entry.spec.ts
+++ b/packages/app/e2e/browser/new-workspace-entry.spec.ts
@@ -95,7 +95,7 @@ test.describe("New workspace entry points", () => {
}
});
- test("the New Workspace screen hides the host selector when there is only one host", async ({
+ test("the New Workspace screen shows the host selector with Add host when there is only one host", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" });
@@ -120,7 +120,12 @@ test.describe("New workspace entry points", () => {
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
timeout: 30_000,
});
- await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
+ const hostTrigger = page.getByTestId("host-picker-trigger");
+ await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
+ await hostTrigger.click();
+ await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
+ await page.getByTestId("new-workspace-host-picker-add-host").click();
+ await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
} finally {
await seeded.cleanup();
}
diff --git a/packages/app/e2e/browser/new-workspace-live-pin.spec.ts b/packages/app/e2e/browser/new-workspace-live-pin.spec.ts
new file mode 100644
index 0000000000..3ed0755fc9
--- /dev/null
+++ b/packages/app/e2e/browser/new-workspace-live-pin.spec.ts
@@ -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 {
+ 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> {
+ 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();
+ }
+ });
+});
diff --git a/packages/app/e2e/browser/plugin-buttons.spec.ts b/packages/app/e2e/browser/plugin-buttons.spec.ts
index 22eb61910b..fe4227a8c3 100644
--- a/packages/app/e2e/browser/plugin-buttons.spec.ts
+++ b/packages/app/e2e/browser/plugin-buttons.spec.ts
@@ -1,15 +1,24 @@
+import { verifyPluginToolTranslations } from "../support/helpers/plugin-translations";
import { test } from "../support/fixtures";
import { withButtonShowcase } from "../support/helpers/plugin-buttons";
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
page,
-}) => {
- test.setTimeout(180_000);
- await withButtonShowcase(page, async (buttons) => {
+}, info) => {
+ test.setTimeout(240_000);
+ await withButtonShowcase(page, info, async (buttons) => {
await buttons.verifyObservationLifetime();
await buttons.openWideMenusAndPopovers();
await buttons.runAndUpdateActions();
await buttons.openCompactSheets();
+ await buttons.useCompactWorkspaceMenu();
await buttons.hideAndUnloadOpenButtons();
});
});
+
+test("plugin tooltips and menu labels follow app language with a panel draft open", async ({
+ page,
+}, info) => {
+ test.setTimeout(180_000);
+ await verifyPluginToolTranslations(page, info);
+});
diff --git a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts
index 0d1ab23476..460faeb4ef 100644
--- a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts
+++ b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts
@@ -71,10 +71,6 @@ async function captureWideAndCompact(
await ready();
}
-function filePanelSwitcher(page: Page) {
- return page.getByTestId("file-panel-switcher").filter({ visible: true });
-}
-
/** Turns on vim keybindings without discarding settings the test writes later. */
async function enableVimKeybindings(page: Page) {
await page.addInitScript(() => {
@@ -94,15 +90,29 @@ async function replaceMarkdown(page: Page, text: string) {
await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text);
}
-async function selectPanel(page: Page, title: string) {
- await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click();
+async function openWithFromTabMenu(page: Page, name: string, option: string) {
+ await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
+ await page.getByRole("menuitem", { name: "Open with…" }).hover();
+ await page.getByRole("menuitem", { name: option }).click();
}
-async function expectPanels(page: Page, titles: string[]) {
- for (const title of titles) {
- await expect(
- filePanelSwitcher(page).getByRole("button", { name: title, exact: true }),
- ).toBeInViewport({ ratio: 1 });
+async function selectFileView(page: Page, view: "Preview" | "Source") {
+ const option = page
+ .getByTestId("file-preview-mode")
+ .filter({ visible: true })
+ .getByRole("button", { name: view, exact: true });
+ await option.click();
+ 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();
}
}
@@ -126,9 +136,6 @@ async function expectMarkdownEditor(page: Page, text: string) {
await expect(editor).toBeVisible();
await expect(editor).toContainText(text);
await expect(editor).toBeInViewport({ ratio: 1 });
- // Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu.
- await expect(filePanelSwitcher(page)).toContainText("Rich Markdown");
- await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 });
}
test("directory examples mount the PDF frame and write Markdown through the file handle", async ({
@@ -197,7 +204,7 @@ test("directory examples mount the PDF frame and write Markdown through the file
await expect(
page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }),
).toBeVisible();
- await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
+ await expect(page.getByRole("radio")).toHaveCount(2);
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
// The previous layout's picker animates out (the sheet slides down, the dialog fades), so
// both pickers are mounted for a moment after a resize.
@@ -220,32 +227,26 @@ test("directory examples mount the PDF frame and write Markdown through the file
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
- await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]);
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
await expectMarkdownEditor(page, "Edited by Rich Markdown");
});
- // Source is Paseo's editor; its cursor and vim mode are in its own status row.
- await selectPanel(page, "Source");
+ 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");
+ await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible();
+ await selectFileView(page, "Source");
const source = page.getByTestId("file-source-editor").filter({ visible: true });
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
await source.locator(".cm-line").nth(2).click();
- const bar = page.getByTestId("file-source-status").filter({ visible: true });
+ const bar = page.getByTestId("file-panel-bar").filter({ visible: true });
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
await capture(page, testInfo, "markdown-example-source-wide");
- // The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source.
- await page.setViewportSize(COMPACT);
- await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
- await filePanelSwitcher(page).click();
- await page.getByRole("menuitem", { name: "Source", exact: true }).click();
- await expect(filePanelSwitcher(page)).toHaveText("Source");
- await source.locator(".cm-line").nth(2).click();
- await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport();
- await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport();
- await capture(page, testInfo, "markdown-example-source-compact");
- await page.setViewportSize(WIDE);
- await selectPanel(page, "Rich Markdown");
+ await openWithFromTabMenu(page, "notes.md", "Rich Markdown — markdown-panel");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText(
"# External update",
@@ -266,7 +267,6 @@ test("directory examples mount the PDF frame and write Markdown through the file
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await page.setViewportSize(COMPACT);
await expectMarkdownEditor(page, "Remembered panel");
- await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
} finally {
await client.removePlugin("pdf-panel");
await client.removePlugin("markdown-panel");
diff --git a/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts b/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts
index 792ace7700..0fc47d7a16 100644
--- a/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts
+++ b/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts
@@ -30,17 +30,26 @@ async function openWithFromTabMenu(page: Page, name: string, option: string) {
await page.getByRole("menuitem", { name: option }).click();
}
-async function toggleAlwaysUseFromFileBar(page: Page) {
- await page.getByTestId("file-panel-bar-menu").filter({ visible: true }).click();
- await page.getByRole("menuitem", { name: "Always use for .md" }).click();
+async function toggleAlwaysUseFromTabMenu(page: Page, name: string) {
+ await openWithFromTabMenu(page, name, "Always use for .md");
}
-function filePanelSwitcher(page: Page) {
- return page.getByTestId("file-panel-switcher").filter({ visible: true });
+/** The built-in panel's toolbar control between its Preview and Source views. */
+function viewControl(page: Page) {
+ return page.getByTestId("file-preview-mode").filter({ visible: true });
}
-async function expectSwitcherPanels(page: Page, titles: string[]) {
- await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles);
+async function selectFileView(page: Page, view: "Preview" | "Source") {
+ const option = viewControl(page).getByRole("button", { name: view, exact: true });
+ await option.click();
+ await expect(option).toHaveAttribute("aria-selected", "true");
+}
+
+async function expectNoOpenWith(page: Page, name: string) {
+ await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
+ await expect(page.getByRole("menuitem", { name: "Close", exact: true })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: "Open with…" })).toHaveCount(0);
+ await page.keyboard.press("Escape");
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
@@ -49,7 +58,7 @@ async function capture(page: Page, testInfo: TestInfo, name: string) {
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
-test("opens Markdown with the built-in panel and Source beside it without asking", async ({
+test("opens Markdown with the built-in panel and its Preview and Source without asking", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
@@ -62,19 +71,20 @@ test("opens Markdown with the built-in panel and Source beside it without asking
await openFileExplorer(page);
await openMarkdown(page, "notes.md");
await expectBuiltinMarkdown(page, "notes heading");
- await expectSwitcherPanels(page, ["Markdown", "Source"]);
+ await expect(viewControl(page).getByRole("button")).toHaveText(["Preview", "Source"]);
await expect(filePanelPicker(page)).toBeHidden();
+ await expectNoOpenWith(page, "notes.md");
await capture(page, testInfo, "file-panel-builtins-wide");
- await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click();
+ await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
- await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click();
+ await selectFileView(page, "Preview");
await expectBuiltinMarkdown(page, "notes heading");
await page.setViewportSize({ width: 390, height: 844 });
await expectBuiltinMarkdown(page, "notes heading");
await expect(
- filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }),
+ viewControl(page).getByRole("button", { name: "Source", exact: true }),
).toBeInViewport({ ratio: 1 });
await expect(filePanelPicker(page)).toBeHidden();
await capture(page, testInfo, "file-panel-builtins-compact");
@@ -107,6 +117,7 @@ test("asks once which panel opens Markdown and remembers it when told to", async
await expect(filePanelPicker(page)).toBeVisible();
await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked();
await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked();
+ await expect(page.getByRole("radio")).toHaveCount(2);
await expectBuiltinMarkdown(page, "first heading");
await capture(page, testInfo, "file-panel-picker-wide");
@@ -118,8 +129,8 @@ test("asks once which panel opens Markdown and remembers it when told to", async
await expectPluginMarkdown(page, "second.md");
await expect(filePanelPicker(page)).toBeHidden();
- // Forgetting from the file bar keeps the tab on the plugin.
- await toggleAlwaysUseFromFileBar(page);
+ // Forgetting from the tab menu keeps the tab on the plugin.
+ await toggleAlwaysUseFromTabMenu(page, "second.md");
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
await expectPluginMarkdown(page, "second.md");
@@ -130,10 +141,14 @@ test("asks once which panel opens Markdown and remembers it when told to", async
await expectPluginMarkdown(page, "third.md");
expect(await storedFilePanelAssociations(page)).toEqual({});
- // "Open with…" switches the open tab back to the built-in in place.
+ // "Open with…" switches the open tab back to the built-in in place, with its own views.
await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION);
await expectBuiltinMarkdown(page, "third heading");
await expectFileTabOpen(page, "third.md");
+ await selectFileView(page, "Source");
+ await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
+ await selectFileView(page, "Preview");
+ await expectBuiltinMarkdown(page, "third heading");
// Dismissing keeps the built-in; reopening the tab after a reload asks again.
await openMarkdown(page, "fourth.md");
diff --git a/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts b/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts
index d6b6f6c986..7f9350ed05 100644
--- a/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts
+++ b/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts
@@ -7,7 +7,6 @@ import {
BUILTIN_MARKDOWN_OPTION,
MARKDOWN_PANEL_ID,
PLUGIN_MARKDOWN_OPTION,
- SOURCE_OPTION,
expectPluginMarkdown,
filePanelPicker,
installMarkdownPanelPlugin,
@@ -18,6 +17,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { openSettingsSection } from "../support/helpers/settings";
const ASK_EVERY_TIME = "Ask every time";
+const SOURCE_OPTION = "Source — Paseo";
function markdownRow(page: Page) {
return page.getByTestId("file-panel-settings-row-ext:md");
@@ -69,7 +69,7 @@ test("Settings > Files chooses which panel opens .md and asks again on request",
await markdownDropdown(page, ASK_EVERY_TIME).click();
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible();
- await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toHaveCount(0);
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-wide-menu");
await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click();
@@ -94,11 +94,11 @@ test("Settings > Files chooses which panel opens .md and asks again on request",
await expect(askAgain(page)).toBeInViewport({ ratio: 1 });
await capture(page, testInfo, "file-panel-settings-compact");
await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click();
- await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-compact-menu");
await page.keyboard.press("Escape");
- await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
+ await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeHidden();
// Ask again forgets the choice; the next open asks.
await askAgain(page).click();
diff --git a/packages/app/e2e/browser/plugin-file-panels.spec.ts b/packages/app/e2e/browser/plugin-file-panels.spec.ts
index 74d4e41329..8567147cda 100644
--- a/packages/app/e2e/browser/plugin-file-panels.spec.ts
+++ b/packages/app/e2e/browser/plugin-file-panels.spec.ts
@@ -91,9 +91,13 @@ async function associateMarkdownWithPlugin(page: Page) {
async function editMarkdownThroughPlugin(page: Page) {
await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click();
}
-async function selectSource(page: Page) {
+/** Opens the tab with Paseo's Markdown panel from "Open with…" and shows its Source view. */
+async function openBuiltinMarkdownSource(page: Page, name: string) {
+ await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
+ await page.getByRole("menuitem", { name: "Open with…" }).hover();
+ await page.getByRole("menuitem", { name: "Markdown — Paseo" }).click();
await page
- .getByTestId("file-panel-switcher")
+ .getByTestId("file-preview-mode")
.filter({ visible: true })
.getByRole("button", { name: "Source", exact: true })
.click();
@@ -210,7 +214,7 @@ test("directory plugins render PDF bytes and write Markdown through the file han
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
await expect.poll(() => closePrompt).toContain("discard");
- await selectSource(page);
+ await openBuiltinMarkdownSource(page, "notes.md");
await expect(
page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"),
).toHaveText("# Changed outside");
diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts
index 75ea218a8f..a195f30aa2 100644
--- a/packages/app/e2e/browser/plugin-host-ui.spec.ts
+++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts
@@ -1,3 +1,4 @@
+import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -9,7 +10,7 @@ import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspac
const PLUGIN_ID = "plugin-host-ui-e2e";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
-import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
+import { Icon, Modal, useToast, Markdown } from "@getpaseo/plugin/client/ui";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -26,6 +27,9 @@ function ModalBody({ onSaved }) {
return
Plugin modal contexts ready
+ {"## Plugin report"}
+ {"Checks **passed**. [Documentation](https://paseo.sh/docs/plugins)"}
+ {""}
Save issue
@@ -91,10 +95,20 @@ async function openPluginModal(page: Page): Promise {
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
}
+async function expectPluginMarkdown(page: Page): Promise {
+ await expect(page.getByRole("heading", { name: "Plugin report" })).toBeVisible();
+ await expect(page.getByText("Checks passed.", { exact: false })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Documentation", exact: true })).toHaveAttribute(
+ "href",
+ "https://paseo.sh/docs/plugins",
+ );
+}
+
async function expectCenteredPluginDialog(page: Page): Promise {
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
+ await expectPluginMarkdown(page);
}
async function closeCenteredPluginDialog(page: Page): Promise {
@@ -106,6 +120,7 @@ async function closeCenteredPluginDialog(page: Page): Promise {
async function expectCompactPluginSheet(page: Page): Promise {
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
+ await expectPluginMarkdown(page);
}
async function savePluginIssue(page: Page): Promise {
@@ -166,3 +181,10 @@ async function playPluginAudio(page: Page, button: string, result: string) {
await page.getByRole("button", { name: button, exact: true }).click();
await expect(page.getByText(result, { exact: true })).toBeVisible();
}
+
+test("plugin content and host labels follow app language without losing an open draft", async ({
+ page,
+}, info) => {
+ test.setTimeout(180_000);
+ await verifyPluginTranslations(page, info);
+});
diff --git a/packages/app/e2e/browser/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts
index bc8e1eedec..165bb168cd 100644
--- a/packages/app/e2e/browser/settings-navigation.spec.ts
+++ b/packages/app/e2e/browser/settings-navigation.spec.ts
@@ -43,6 +43,8 @@ import {
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { expectAppRoute } from "../support/helpers/route-assertions";
+import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
+import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function openWorkspace(
page: import("@playwright/test").Page,
@@ -53,6 +55,74 @@ async function openWorkspace(
}
test.describe("Settings sidebar navigation", () => {
+ test("opening Settings closes the composer model selector until it is opened again", async ({
+ page,
+ withWorkspace,
+ }, testInfo) => {
+ const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
+ await gotoWorkspace(page, workspace.workspaceId);
+ await clickNewChat(page);
+
+ const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
+ const popover = page.getByTestId("combobox-desktop-container");
+ await selector.click();
+ await expect(popover).toBeVisible();
+
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
+ await page.keyboard.press(`${modifier}+,`);
+ await expect(page).toHaveURL(/\/settings\/general$/);
+ await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
+ await expect(popover).toHaveCount(0);
+
+ await clickSettingsBackToWorkspace(page);
+ await expect(selector).toBeVisible();
+ await expect(popover).toHaveCount(0);
+
+ await selector.click();
+ await expect(popover).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(popover).toHaveCount(0);
+ });
+
+ test("opening Settings closes an existing chat's model selector until it is opened again", async ({
+ page,
+ }, testInfo) => {
+ const workspace = await seedMockAgentWorkspace({
+ repoPrefix: "settings-existing-model-",
+ title: "Settings model selector",
+ });
+ try {
+ await openAgentRoute(page, {
+ workspaceId: workspace.workspaceId,
+ agentId: workspace.agentId,
+ });
+
+ const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
+ const popover = page.getByTestId("combobox-desktop-container");
+ await selector.click();
+ await expect(popover).toBeVisible();
+
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
+ await page.keyboard.press(`${modifier}+,`);
+ await expect(page).toHaveURL(/\/settings\/general$/);
+ await page.screenshot({
+ path: testInfo.outputPath("settings-after-existing-model-selector.png"),
+ });
+ await expect(popover).toHaveCount(0);
+
+ await clickSettingsBackToWorkspace(page);
+ await expect(selector).toBeVisible();
+ await expect(popover).toHaveCount(0);
+
+ await selector.click();
+ await expect(popover).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(popover).toHaveCount(0);
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
@@ -147,6 +217,42 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
+ test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
+ const workspace = await seedMockAgentWorkspace({
+ repoPrefix: "settings-tab-switcher-",
+ title: "Compact tab switcher",
+ });
+ try {
+ await openAgentRoute(page, {
+ workspaceId: workspace.workspaceId,
+ agentId: workspace.agentId,
+ });
+
+ const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
+ const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
+ await switcher.click();
+ await expect(newAgent).toBeInViewport();
+
+ // Navigate without dismissing the sheet through its backdrop first.
+ await page
+ .getByTestId("menu-button")
+ .filter({ visible: true })
+ .first()
+ .dispatchEvent("click");
+ await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
+ await expectAppRoute(page, buildSettingsRoute());
+ await expect(newAgent).toHaveCount(0);
+
+ await closeCompactSettings(page);
+ await expect(switcher).toBeVisible();
+ await expect(newAgent).toHaveCount(0);
+ await switcher.click();
+ await expect(newAgent).toBeInViewport();
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
test("opens compact app and host details and returns through the settings list", async ({
page,
}) => {
diff --git a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts
index 2c1fd0cc24..41da8ac3fa 100644
--- a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts
+++ b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts
@@ -10,6 +10,16 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
await page.keyboard.press(`${modifier}+Comma`);
}
+async function openComposerTab(page: import("@playwright/test").Page, agentId: string) {
+ await page
+ .getByTestId(`workspace-tab-agent_${agentId}`)
+ .filter({ visible: true })
+ .first()
+ .click();
+ await expectAgentTabActive(page, agentId);
+ await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
+}
+
interface ComposerFrame {
at: number;
visible: boolean;
@@ -27,7 +37,9 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page)
const sample = () => {
if (capture.stopped) return;
- const root = document.querySelector('[data-testid="message-input-root"]');
+ const root = Array.from(
+ document.querySelectorAll('[data-testid="message-input-root"]'),
+ ).find((candidate) => candidate.offsetParent !== null);
const input = root?.querySelector("textarea");
if (root && input) {
const style = getComputedStyle(input);
@@ -178,6 +190,8 @@ test.describe("Settings toggle tab regression", () => {
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
+ await openComposerTab(page, firstAgent.id);
+ await openComposerTab(page, secondAgent.id);
const input = page.getByRole("textbox", { name: "Message agent..." });
await input.fill("Keep this short draft in the composer");
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
@@ -194,6 +208,7 @@ test.describe("Settings toggle tab regression", () => {
const frames = await stopComposerFrameCapture(page);
const visibleFrames = frames.filter((frame) => frame.visible);
+ expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0);
const tallestIndex = visibleFrames.reduce(
(index, frame, candidateIndex) =>
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
diff --git a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts
index 8b06e14f67..92e4d7a095 100644
--- a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts
+++ b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts
@@ -25,6 +25,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
+ /^Sort/,
/^Title/,
"Show",
"Project",
diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts
index a17480b740..b557b73ccb 100644
--- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts
+++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts
@@ -1,10 +1,12 @@
import { expect, test } from "../support/fixtures";
+import type { Page } from "@playwright/test";
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
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";
@@ -31,6 +33,28 @@ import {
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
+async function showDeveloperToast(page: Page): Promise {
+ await page.evaluate(() => {
+ document.getElementById("error-toast")?.remove();
+ const toast = document.createElement("div");
+ toast.id = "error-toast";
+ toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white";
+ // Expo renders its unnamed dismiss button in an open shadow root.
+ const button = document.createElement("button");
+ button.style.cssText = "width:24px;height:24px";
+ button.addEventListener("click", () => toast.remove());
+ toast.attachShadow({ mode: "open" }).appendChild(button);
+ document.body.appendChild(toast);
+ });
+}
+
+test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => {
+ await gotoAppShell(page);
+ await showDeveloperToast(page);
+ await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
+ await expect(page.locator("#error-toast")).toBeHidden();
+});
+
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
@@ -67,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");
diff --git a/packages/app/e2e/browser/sidebar-project-filter.spec.ts b/packages/app/e2e/browser/sidebar-project-filter.spec.ts
index a2f8d14348..e406ba5a3f 100644
--- a/packages/app/e2e/browser/sidebar-project-filter.spec.ts
+++ b/packages/app/e2e/browser/sidebar-project-filter.spec.ts
@@ -32,6 +32,7 @@ test.describe("Sidebar project filter", () => {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
+ /^Sort/,
/^Title/,
"Show",
"Project",
diff --git a/packages/app/e2e/browser/sidebar-reorder.spec.ts b/packages/app/e2e/browser/sidebar-reorder.spec.ts
index 6129d37a63..03ffe9f7db 100644
--- a/packages/app/e2e/browser/sidebar-reorder.spec.ts
+++ b/packages/app/e2e/browser/sidebar-reorder.spec.ts
@@ -1,9 +1,10 @@
-import type { Locator } from "@playwright/test";
+import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { getServerId } from "../support/helpers/server-id";
import { seedWorkspace } from "../support/helpers/seed-client";
+import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
async function rowTestIds(rows: Locator) {
@@ -57,7 +58,19 @@ async function quickDragFirstRowAfterSecond(
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
}
-test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({
+async function dragWithoutReordering(rows: Locator): Promise {
+ const before = await rowTestIds(rows);
+ const source = await visibleBoundingBox(rows.nth(0));
+ const target = await visibleBoundingBox(rows.nth(1));
+ const page = rows.page();
+ await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
+ await page.mouse.up();
+ await expect.poll(() => rowTestIds(rows)).toEqual(before);
+}
+
+test("projects and pinned chats reorder while ordinary workspaces stay newest first", async ({
page,
}) => {
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
@@ -87,12 +100,13 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra
);
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
- await quickDragFirstRowAfterSecond(
- page.locator(
- `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
- ),
- pressWorkspaceRow,
+ const ordinaryRows = page.locator(
+ `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
);
+ await expect
+ .poll(() => rowTestIds(ordinaryRows))
+ .toEqual([secondWorkspaceTestId, firstWorkspaceTestId]);
+ await dragWithoutReordering(ordinaryRows);
await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true);
await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true);
@@ -108,3 +122,300 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra
await secondProject.cleanup();
}
});
+
+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();
+}
+
+async function reconnectSidebar(page: Page): Promise {
+ await page.reload();
+ await waitForSidebarHydration(page);
+}
+
+async function expectNoWorkspaceSortChoice(page: Page): Promise {
+ await page.getByLabel("Display preferences", { exact: true }).click();
+ await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0);
+}
+
+async function promptWorkspace(seed: Awaited>, agentId: string) {
+ await seed.client.sendAgentMessage(agentId, "sidebar ordering check");
+ expect((await seed.client.waitForFinish(agentId, 30_000)).status).toBe("idle");
+}
+
+test("Created stays stable, Last prompt follows human submissions and survives tab archive", async ({
+ page,
+}) => {
+ test.setTimeout(120_000);
+ const seed = await seedWorkspace({ repoPrefix: "sidebar-sort-" });
+ try {
+ const newer = await seed.client.createWorkspace({
+ source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
+ title: "Newer workspace",
+ });
+ if (!newer.workspace) throw new Error(newer.error ?? "Workspace creation failed");
+ const agent = await seed.client.createAgent({
+ provider: "mock",
+ cwd: seed.repoPath,
+ workspaceId: seed.workspaceId,
+ modeId: "load-test",
+ model: "e2e-fast-stream",
+ title: "Ordering agent",
+ });
+ const newerAgent = await seed.client.createAgent({
+ provider: "mock",
+ cwd: seed.repoPath,
+ workspaceId: newer.workspace.id,
+ modeId: "load-test",
+ model: "e2e-fast-stream",
+ title: "Newer agent",
+ });
+ await gotoAppShell(page);
+ await waitForSidebarHydration(page);
+ const oldId = `sidebar-workspace-row-${getServerId()}:${seed.workspaceId}`;
+ const newId = `sidebar-workspace-row-${getServerId()}:${newer.workspace.id}`;
+ const rows = page.locator(`[data-testid="${oldId}"], [data-testid="${newId}"]`);
+ await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
+ await promptWorkspace(seed, agent.id);
+ await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
+ await selectWorkspaceSort(page, "Last prompt");
+ await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
+ await promptWorkspace(seed, newerAgent.id);
+ await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
+ await promptWorkspace(seed, agent.id);
+ await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
+ await seed.client.archiveAgent(agent.id);
+ await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
+ await reconnectSidebar(page);
+ await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
+ await selectWorkspaceSort(page, "Created");
+ await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
+ await selectSidebarStatusGrouping(page);
+ await expectNoWorkspaceSortChoice(page);
+ } finally {
+ await seed.cleanup();
+ }
+});
+
+async function seedCompactProject() {
+ const seed = await seedWorkspace({
+ repoPrefix: "sidebar-visibility-",
+ title: "Oldest workspace",
+ });
+ const ids = [seed.workspaceId];
+ for (let index = 2; index <= 12; index += 1) {
+ const created = await seed.client.createWorkspace({
+ source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
+ title: `Workspace ${index}`,
+ });
+ if (!created.workspace) throw new Error(created.error ?? "Workspace creation failed");
+ ids.unshift(created.workspace.id);
+ }
+ const agent = await seed.client.createAgent({
+ provider: "mock",
+ model: "e2e-fast-stream",
+ modeId: "load-test",
+ cwd: seed.repoPath,
+ workspaceId: seed.workspaceId,
+ title: "Overflow agent",
+ });
+ return { seed, ids, agent };
+}
+
+async function expectWorkspaceOrder(rows: Locator, ids: string[]) {
+ await expect
+ .poll(() => rowTestIds(rows))
+ .toEqual(ids.map((id) => `sidebar-workspace-row-${getServerId()}:${id}`));
+}
+
+async function revealWorkspaces(list: Locator, action: "Show more" | "Show less") {
+ await list.getByLabel(action, { exact: true }).click();
+}
+
+async function selectProjectGrouping(page: Page) {
+ await page.getByLabel("Display preferences", { exact: true }).click();
+ await page.getByRole("menuitem", { name: /^Group/ }).click();
+ await page.getByTestId("sidebar-grouping-project").click();
+}
+
+type CompactProject = Awaited>;
+
+function compactProjectRows(page: Page, seed: CompactProject["seed"]) {
+ const list = page.getByTestId(`sidebar-workspace-list-${seed.projectKey}`);
+ return {
+ rows: list.locator('[data-testid^="sidebar-workspace-row-"]'),
+ group: list.locator(".."),
+ project: page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seed.projectKey)}`),
+ };
+}
+
+async function expectActiveOverflowTransitions(page: Page, { seed, ids, agent }: CompactProject) {
+ const { rows } = compactProjectRows(page, seed);
+ await expectWorkspaceOrder(rows, ids.slice(0, 7));
+
+ await seed.client.sendAgentMessage(agent.id, "show active overflow");
+ await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]);
+ await seed.client.waitForFinish(agent.id, 15_000);
+ await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]);
+ await seed.client.clearWorkspaceAttention(seed.workspaceId);
+ await expectWorkspaceOrder(rows, ids.slice(0, 7));
+}
+
+async function expectRevealSurvivesSortingAndCollapse(
+ page: Page,
+ { seed, ids, agent }: CompactProject,
+) {
+ const { rows, group, project } = compactProjectRows(page, seed);
+ const retainedRows = await rows.elementHandles();
+ await revealWorkspaces(group, "Show more");
+ await expectWorkspaceOrder(rows, ids);
+ for (const [index, retained] of retainedRows.entries()) {
+ expect(
+ await rows.nth(index).evaluate((current, previous) => current === previous, retained),
+ ).toBe(true);
+ }
+ await project.click();
+ await expect(rows).toHaveCount(0);
+ await project.click();
+ await expectWorkspaceOrder(rows, ids);
+ await seed.client.sendAgentMessage(agent.id, "expanded overflow");
+ await seed.client.waitForFinish(agent.id, 15_000);
+ await seed.client.clearWorkspaceAttention(seed.workspaceId);
+ await expectWorkspaceOrder(rows, ids);
+ await selectWorkspaceSort(page, "Last prompt");
+ await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 11)]);
+ const revealedRow = await rows.nth(8).elementHandle();
+ await revealWorkspaces(group, "Show less");
+ await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 6)]);
+ await selectWorkspaceSort(page, "Created");
+ await expectWorkspaceOrder(rows, ids.slice(0, 7));
+ expect(await revealedRow!.evaluate((row) => row.isConnected)).toBe(false);
+ await reconnectSidebar(page);
+ await expectWorkspaceOrder(rows, ids.slice(0, 7));
+}
+
+async function expectStatusBucketsRetainActiveOverflow(
+ page: Page,
+ { seed, ids, agent }: CompactProject,
+) {
+ const { rows } = compactProjectRows(page, seed);
+ await selectSidebarStatusGrouping(page);
+ const done = page.getByTestId("sidebar-status-group-rows-done");
+ const doneRows = done.locator('[data-testid^="sidebar-workspace-row-"]');
+ await expect(doneRows).toHaveCount(7);
+ await revealWorkspaces(done, "Show more");
+ await expect(doneRows).toHaveCount(12);
+ await seed.client.sendAgentMessage(agent.id, "status overflow");
+ await seed.client.waitForFinish(agent.id, 15_000);
+ await expect(
+ page
+ .getByTestId("sidebar-status-group-rows-attention")
+ .getByText("Oldest workspace", { exact: true }),
+ ).toBeVisible();
+ await expect(doneRows).toHaveCount(11);
+ await seed.client.clearWorkspaceAttention(seed.workspaceId);
+ await expect(doneRows).toHaveCount(12);
+ await revealWorkspaces(done, "Show less");
+ await expect(doneRows).toHaveCount(7);
+ const activeAgents: Array<{ id: string }> = [];
+ for (const workspaceId of ids.slice(0, 8)) {
+ activeAgents.push(
+ await seed.client.createAgent({
+ provider: "mock",
+ model: "e2e-fast-stream",
+ modeId: "load-test",
+ cwd: seed.repoPath,
+ workspaceId,
+ title: "Active overflow agent",
+ }),
+ );
+ }
+ await Promise.all(
+ activeAgents.map((item) => seed.client.sendAgentMessage(item.id, "active bucket")),
+ );
+ await Promise.all(activeAgents.map((item) => seed.client.waitForFinish(item.id, 15_000)));
+ const attention = page.getByTestId("sidebar-status-group-rows-attention");
+ await expect(attention.locator('[data-testid^="sidebar-workspace-row-"]')).toHaveCount(8);
+ await expect(attention.getByLabel("Show more", { exact: true })).toHaveCount(0);
+ for (const workspaceId of ids.slice(0, 8)) await seed.client.clearWorkspaceAttention(workspaceId);
+ await expect(doneRows).toHaveCount(7);
+ await selectProjectGrouping(page);
+ await expectWorkspaceOrder(rows, ids.slice(0, 7));
+}
+
+test("compact groups retain active overflow and reveal the current ordered rows", async ({
+ page,
+}) => {
+ test.setTimeout(120_000);
+ const fixture = await seedCompactProject();
+ try {
+ await gotoAppShell(page);
+ await waitForSidebarHydration(page);
+ await test.step("Active overflow appears without intervening Done rows", () =>
+ expectActiveOverflowTransitions(page, fixture));
+ await test.step("Reveal follows current sort and survives collapse", () =>
+ expectRevealSurvivesSortingAndCollapse(page, fixture));
+ await test.step("Status reveals Done rows and keeps every active row", () =>
+ expectStatusBucketsRetainActiveOverflow(page, fixture));
+ } finally {
+ 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();
+ }
+});
diff --git a/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts
new file mode 100644
index 0000000000..757b8908c2
--- /dev/null
+++ b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts
@@ -0,0 +1,87 @@
+import type { Page } from "@playwright/test";
+import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
+import { randomUUID } from "node:crypto";
+import { metroTest as test, expect } from "../support/fixtures";
+import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
+import { seedWorkspace } from "../support/helpers/seed-client";
+import { buildSeededHost } from "../support/helpers/daemon-registry";
+import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
+
+test("published old daemon keeps workspace navigation and gates sorting", async ({ page }) => {
+ test.setTimeout(180_000);
+ const daemon = await startIsolatedHostDaemon(`srv_sort_old_${randomUUID()}`, {
+ publishedVersion: "0.8.0",
+ });
+ try {
+ const project = await seedWorkspace({ repoPrefix: "sidebar-old-host-", port: daemon.port });
+ try {
+ const second = await project.client.createWorkspace({
+ source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
+ title: "Second old workspace",
+ });
+ expect(second.workspace).toBeTruthy();
+ await openPublishedHost(page, daemon, project.workspaceId);
+ await openWorkspace(page, daemon.serverId, second.workspace!.id);
+ await expectSortingRequiresHostUpdate(page);
+ await selectSidebarStatusGrouping(page);
+ await expectStatusHasNoSorting(page);
+ } finally {
+ await project.cleanup();
+ }
+ } finally {
+ await daemon.close();
+ }
+});
+
+async function openPublishedHost(
+ page: Page,
+ daemon: { serverId: string; port: number },
+ workspaceId: string,
+) {
+ const host = buildSeededHost({
+ serverId: daemon.serverId,
+ endpoint: `127.0.0.1:${daemon.port}`,
+ nowIso: new Date().toISOString(),
+ });
+ await page.addInitScript((seededHost) => {
+ localStorage.setItem("@paseo:e2e", "1");
+ localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
+ localStorage.setItem(
+ "sidebar-view",
+ JSON.stringify({ version: 8, state: { groupMode: "project", workspaceSort: "lastPrompt" } }),
+ );
+ }, host);
+ await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId));
+}
+
+async function openWorkspace(page: Page, serverId: string, workspaceId: string) {
+ const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
+ await expect(row).toBeVisible();
+ await row.click();
+ await expect(page).toHaveURL(new RegExp(workspaceId));
+}
+
+async function expectSortingRequiresHostUpdate(page: Page) {
+ await page.getByLabel("Display preferences", { exact: true }).click();
+ await page.getByRole("menuitem", { name: /^Sort/ }).click();
+ await expect(
+ page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }),
+ ).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");
+}
+
+async function expectStatusHasNoSorting(page: Page) {
+ await page.getByLabel("Display preferences", { exact: true }).click();
+ await expect(page.getByRole("menuitem", { name: "Update hosts to sort workspaces" })).toHaveCount(
+ 0,
+ );
+ await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0);
+}
diff --git a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts
index bc3d4bbf95..ccf29179b8 100644
--- a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts
+++ b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts
@@ -6,6 +6,7 @@ import {
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
+import { expectOpenTabCount } from "../support/helpers/sheet";
import { getServerId } from "../support/helpers/server-id";
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
@@ -179,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);
}
@@ -227,3 +229,45 @@ test("manual unread survives leaving the current workspace on compact layout", a
await markUnreadThenResumeChat(page, workspaces.subject.workspaceId);
await completeTurnAndLeaveOnCompact(page, workspaces);
});
+
+async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
+ const route = page.url();
+ // End this app session before the turn, so its cached transcript cannot contain the new prompt.
+ await page.goto("about:blank");
+ await workspace.client.sendAgentMessage(
+ workspace.agentId,
+ "A turn completed while the app was closed.",
+ );
+ await workspace.client.waitForFinish(workspace.agentId, 20_000);
+ // Reopen the ordinary workspace route, without an agent open intent that would repair focus.
+ await page.goto(route);
+ await expect(page.getByRole("button", { name: /Switch tabs/ })).toBeVisible();
+}
+
+async function expectRestoredCompactChat(page: Page) {
+ await expectOpenTabCount(page, 1);
+ await expect(
+ page.getByText("A turn completed while the app was closed.", { exact: true }),
+ ).toBeVisible();
+}
+
+test("reopening a marked unread workspace restores only its agent tab and current history on compact layout", async ({
+ page,
+ workspaces,
+}) => {
+ test.setTimeout(120_000);
+ await test.step("open a finished chat with one main tab", async () => {
+ await openCompactWorkspace(page, workspaces.subject);
+ await closeMobileAgentSidebar(page);
+ await expectOpenTabCount(page, 1);
+ await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
+ });
+ await test.step("mark the current workspace unread, then close and reopen the app", async () => {
+ await openMobileAgentSidebar(page);
+ await markAsUnread(page, workspaces.subject.workspaceId);
+ await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject);
+ });
+ await test.step("restore the main tab without Explorer entries and load its latest history", async () => {
+ await expectRestoredCompactChat(page);
+ });
+});
diff --git a/packages/app/e2e/browser/subagent-origin-pane.spec.ts b/packages/app/e2e/browser/subagent-origin-pane.spec.ts
index 47a0120da6..30b19e5099 100644
--- a/packages/app/e2e/browser/subagent-origin-pane.spec.ts
+++ b/packages/app/e2e/browser/subagent-origin-pane.spec.ts
@@ -1,10 +1,19 @@
+import { buildSeededHost } from "../support/helpers/daemon-registry";
+import { submitMessage } from "../support/helpers/composer";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
-import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
+import {
+ delegateFleetThroughMcp,
+ expectManagedSubagentArchived,
+ openSubagentsTrack,
+ seedParentWithSubagent,
+} from "../support/helpers/subagents";
+
+test.use({ e2eInjectPaseoTools: true });
const SETTINGS_KEY = "@paseo:app-settings";
@@ -147,3 +156,189 @@ test("a preferred chat file link still opens beside its right-pane agent", async
await workspace.cleanup();
}
});
+
+test("MCP delegates to self and B and opens both qualified children in ordinary conversations", async ({
+ page,
+}) => {
+ const fleet = await delegateFleetThroughMcp();
+ let appSockets = 0;
+ let routeOpens = 0;
+ const activeDaemonSockets = new Set();
+ const observationRequests: Record = {};
+ page.on("websocket", (socket) => {
+ appSockets++;
+ if (new URL(socket.url()).pathname === "/ws") {
+ activeDaemonSockets.add(socket);
+ socket.on("close", () => activeDaemonSockets.delete(socket));
+ }
+ socket.on("framesent", ({ payload }) => {
+ if (typeof payload !== "string" || !payload.startsWith("{")) return;
+ if (String(payload).includes('"hosts.route.open.request"')) routeOpens++;
+ const outer = JSON.parse(String(payload));
+ const request = outer.type === "session" ? outer.message : null;
+ if (
+ request?.type &&
+ (request.subscribe !== undefined ||
+ request.observe === true ||
+ request.type.endsWith("set_subscription.request"))
+ )
+ observationRequests[request.type] = (observationRequests[request.type] ?? 0) + 1;
+ });
+ });
+ try {
+ await openAgentRoute(page, {
+ workspaceId: fleet.workspace.workspaceId,
+ agentId: fleet.parent.id,
+ });
+ await openSubagentsTrack(page);
+ await expect(page.getByTestId(`subagents-track-row-${fleet.local.agentId}`)).toContainText(
+ "MCP child on A",
+ );
+ await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
+ "MCP child on B",
+ );
+ await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
+ "Fleet B",
+ );
+ await page.getByTestId(`subagents-track-row-${fleet.local.agentId}`).click();
+ await expect(page.getByTestId(`workspace-tab-agent_${fleet.local.agentId}`)).toBeVisible();
+ await submitMessage(page, "LOCAL_UI_TARGET");
+ await expect(page.getByText("LOCAL_UI_TARGET", { exact: true })).toBeVisible();
+ expect((await fleet.b.fetchAgent(fleet.local.agentId))?.agent.lastUserMessageAt).toBeNull();
+ await page.getByTestId(`workspace-tab-agent_${fleet.parent.id}`).click();
+ await openSubagentsTrack(page);
+ await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click();
+ await expect(page).toHaveURL((url) =>
+ url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`),
+ );
+ await submitMessage(page, "REMOTE_UI_TARGET");
+ await expect(page.getByText("REMOTE_UI_TARGET", { exact: true })).toBeVisible();
+ expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull();
+ await fleet.expectCompletion(fleet.local);
+ await fleet.expectCompletion(fleet.remote);
+ await openAgentRoute(page, {
+ workspaceId: fleet.workspace.workspaceId,
+ agentId: fleet.parent.id,
+ });
+ await expect(
+ page.getByRole("button", { name: /Agent finished.*MCP child on A/ }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: /Agent finished.*MCP child on B/ }),
+ ).toBeVisible();
+ const owners = await page.evaluate(() => {
+ const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as {
+ getHosts(): Array<{ serverId: string }>;
+ getSnapshot(
+ serverId: string,
+ ): { connectionStatus: string; clientGeneration: number } | null;
+ };
+ return runtime.getHosts().map((host) => {
+ const snapshot = runtime.getSnapshot(host.serverId);
+ return {
+ serverId: host.serverId,
+ connectionStatus: snapshot?.connectionStatus,
+ clientGeneration: snapshot?.clientGeneration,
+ };
+ });
+ });
+ console.log("Fleet ordinary route counts", {
+ appSockets,
+ activeDaemonSockets: activeDaemonSockets.size,
+ routeOpens,
+ owners,
+ observationRequests,
+ });
+ } finally {
+ await fleet.close();
+ }
+});
+
+async function openFleetParentWithDirectB(
+ page: Page,
+ fleet: Awaited>,
+) {
+ await page.goto("/", { waitUntil: "commit" });
+ const host = buildSeededHost({
+ serverId: fleet.remote.serverId,
+ endpoint: `127.0.0.1:${fleet.destination.port}`,
+ label: "Fleet B",
+ nowIso: new Date().toISOString(),
+ });
+ await page.evaluate((extraHost) => {
+ localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
+ }, host);
+ await openAgentRoute(page, {
+ workspaceId: fleet.workspace.workspaceId,
+ agentId: fleet.parent.id,
+ });
+ await openSubagentsTrack(page);
+ await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
+ "MCP child on B",
+ );
+}
+
+async function openFleetChild(
+ page: Page,
+ fleet: Awaited>,
+) {
+ await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click();
+ await expect(page).toHaveURL(
+ (url) => url.pathname === `/h/${fleet.remote.serverId}/workspace/${fleet.remote.workspaceId}`,
+ );
+}
+
+test("a directly connected B opens and archives its MCP child from A's track", async ({ page }) => {
+ const fleet = await delegateFleetThroughMcp();
+ try {
+ await openFleetParentWithDirectB(page, fleet);
+ await openFleetChild(page, fleet);
+ await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible();
+ await submitMessage(page, "DIRECT_B_TARGET");
+ await expect(page.getByText("DIRECT_B_TARGET", { exact: true })).toBeVisible();
+ expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull();
+ await openFleetParentWithDirectB(page, fleet);
+ await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).hover();
+ page.once("dialog", (dialog) => {
+ void dialog.accept();
+ });
+ await page.getByRole("button", { name: "Archive MCP child on B", exact: true }).click();
+ await expectManagedSubagentArchived({ client: fleet.b }, fleet.remote.agentId);
+ await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toHaveCount(0);
+ expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.archivedAt).toBeNull();
+ await expect(page.getByText(/already connected through another route/)).toHaveCount(0);
+ } finally {
+ await fleet.close();
+ }
+});
+
+test("an unavailable B child opens its cached workspace with existing reconnecting behavior", async ({
+ page,
+}) => {
+ const fleet = await delegateFleetThroughMcp();
+ try {
+ await openFleetParentWithDirectB(page, fleet);
+ await fleet.destination.close();
+ await expect
+ .poll(() =>
+ page.evaluate((serverId) => {
+ const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as {
+ getSnapshot(id: string): { serverId: string; connectionStatus: string } | null;
+ };
+ return runtime.getSnapshot(serverId);
+ }, fleet.remote.serverId),
+ )
+ .toMatchObject({
+ serverId: fleet.remote.serverId,
+ connectionStatus: expect.stringMatching(/^(offline|connecting|error)$/),
+ });
+ await openFleetChild(page, fleet);
+ await expect(page.getByTestId("workspace-header-title").filter({ visible: true })).toHaveText(
+ "B workspace",
+ );
+ await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible();
+ await expect(page.getByText(/already connected through another route/)).toHaveCount(0);
+ } finally {
+ await fleet.close();
+ }
+});
diff --git a/packages/app/e2e/browser/terminal-protocol-query.spec.ts b/packages/app/e2e/browser/terminal-protocol-query.spec.ts
index 023bf12b9d..d5a0071189 100644
--- a/packages/app/e2e/browser/terminal-protocol-query.spec.ts
+++ b/packages/app/e2e/browser/terminal-protocol-query.spec.ts
@@ -1,6 +1,10 @@
+import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import path from "node:path";
+import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
+import { holdFileUploadResponse } from "../support/helpers/file-upload-gate";
+import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf";
@@ -26,12 +30,130 @@ process.stdin.on("data", (chunk) => {
setTimeout(finish, 700);
`;
+// A TUI stand-in: turns on bracketed paste, reads every quoted path in each
+// bracketed paste, and echoes typed lines, so the PTY output proves what reached
+// the terminal and where the file was read from.
+const FILE_RECEIVER_SCRIPT = String.raw`
+const fs = require("node:fs");
+const crypto = require("node:crypto");
+let buffer = "";
+process.stdin.setRawMode(true);
+process.stdin.resume();
+process.stdout.write("\x1b[?2004hFILE_RECEIVER_READY\r\n");
+process.stdin.on("data", (chunk) => {
+ buffer += chunk.toString();
+ for (;;) {
+ const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
+ if (!paste) break;
+ for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
+ const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
+ const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
+ process.stdout.write("FILE_READ:" + digest + ":" + location + "\r\n");
+ }
+ buffer = buffer.slice(0, paste.index) + buffer.slice(paste.index + paste[0].length);
+ }
+ if (buffer.includes("\x03")) {
+ process.stdout.write("INTERRUPTED\r\n");
+ buffer = buffer.replaceAll("\x03", "");
+ }
+ const enter = buffer.indexOf("\r");
+ if (enter >= 0 && !buffer.includes("'")) {
+ process.stdout.write("TYPED:" + buffer.slice(0, enter) + "\r\n");
+ buffer = buffer.slice(enter + 1);
+ }
+});
+`;
+
+function pngBytes(seed: number): Buffer {
+ return Buffer.concat([Buffer.from("iVBORw0KGgo=", "base64"), Buffer.from([seed])]);
+}
+
+function fileRead(bytes: Buffer, location: "upload" | "local"): string {
+ const digest = createHash("sha256").update(bytes).digest("hex");
+ return `FILE_READ:${digest}:${location}`;
+}
+
+// Inactive terminal tabs retain their surfaces, and another page opening a terminal in
+// the same workspace adds a tab here too. Only the selected tab's surface is visible.
+function terminalSurface(page: Page) {
+ return page.getByTestId("terminal-surface").filter({ visible: true });
+}
+
+function terminalInput(page: Page) {
+ return terminalSurface(page).locator(".xterm-helper-textarea");
+}
+
+async function pasteImage(page: Page, bytes: Buffer): Promise {
+ await terminalInput(page).evaluate(
+ (element, data) => {
+ (element as HTMLTextAreaElement).focus();
+ const clipboardData = new DataTransfer();
+ clipboardData.items.add(new File([new Uint8Array(data)], "image.png", { type: "image/png" }));
+ element.dispatchEvent(
+ new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }),
+ );
+ },
+ [...bytes],
+ );
+}
+
+interface DroppedFile {
+ name: string;
+ type: string;
+ bytes: Buffer;
+}
+
+async function dragTransfer(page: Page, files: DroppedFile[]) {
+ return await page.evaluateHandle(
+ (entries) => {
+ const dataTransfer = new DataTransfer();
+ for (const entry of entries) {
+ dataTransfer.items.add(
+ new File([new Uint8Array(entry.bytes)], entry.name, { type: entry.type }),
+ );
+ }
+ return dataTransfer;
+ },
+ files.map((file) => ({ ...file, bytes: [...file.bytes] })),
+ );
+}
+
+async function dragFilesOver(page: Page, files: DroppedFile[]): Promise {
+ const dataTransfer = await dragTransfer(page, files);
+ await terminalInput(page).dispatchEvent("dragenter", { dataTransfer });
+ await terminalInput(page).dispatchEvent("dragover", { dataTransfer });
+ await dataTransfer.dispose();
+}
+
+async function dropFiles(page: Page, files: DroppedFile[]): Promise {
+ const dataTransfer = await dragTransfer(page, files);
+ await terminalInput(page).dispatchEvent("drop", { dataTransfer });
+ await dataTransfer.dispose();
+}
+
+/** The shared "Drop files here" overlay of the zone around the terminal. */
+function terminalDropOverlay(page: Page) {
+ return terminalSurface(page)
+ .locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]")
+ .getByTestId("file-drop-backdrop");
+}
+
+/**
+ * Makes the app treat the e2e daemon as the desktop app's own daemon, so dropped
+ * files resolve to paths inside `directory` instead of uploading.
+ */
+async function typeLine(page: Page, text: string): Promise {
+ await terminalInput(page).pressSequentially(text);
+ await terminalInput(page).press("Enter");
+}
+
test.describe("Terminal protocol queries", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" });
await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT);
+ await writeFile(path.join(harness.tempRepo.path, "file-receiver.cjs"), FILE_RECEIVER_SCRIPT);
});
test.afterAll(async () => {
@@ -58,4 +180,156 @@ test.describe("Terminal protocol queries", () => {
await harness.killTerminal(terminalInstance.id);
}
});
+
+ async function openFileReceiver(page: Page, name: string) {
+ const terminal = await harness.createTerminal({
+ name,
+ command: process.execPath,
+ args: ["file-receiver.cjs"],
+ });
+ await harness.openTerminal(page, { terminalId: terminal.id });
+ await expectTerminalOutput(terminal.id, "FILE_RECEIVER_READY");
+ return terminal;
+ }
+
+ // Reads the PTY itself, so output from one pane cannot satisfy another's check.
+ async function terminalOutput(terminalId: string): Promise {
+ const capture = await harness.client.captureTerminal(terminalId);
+ return capture.lines.join("").replace(/\s/g, "");
+ }
+
+ async function expectTerminalOutput(terminalId: string, text: string): Promise {
+ await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
+ }
+
+ test("pastes images from two panes into their own terminals as uploaded host files", async ({
+ page,
+ }) => {
+ const secondPage = await page.context().newPage();
+ const first = await openFileReceiver(page, "image-paste-a");
+ const second = await openFileReceiver(secondPage, "image-paste-b");
+ try {
+ await Promise.all([pasteImage(page, pngBytes(1)), pasteImage(secondPage, pngBytes(2))]);
+
+ await expectTerminalOutput(first.id, fileRead(pngBytes(1), "upload"));
+ await expectTerminalOutput(second.id, fileRead(pngBytes(2), "upload"));
+ expect(await terminalOutput(first.id)).not.toContain(fileRead(pngBytes(2), "upload"));
+ expect(await terminalOutput(second.id)).not.toContain(fileRead(pngBytes(1), "upload"));
+ } finally {
+ await secondPage.close();
+ await harness.killTerminal(first.id);
+ await harness.killTerminal(second.id);
+ }
+ });
+
+ test("uploads dropped files of any type when the host is another machine", async ({ page }) => {
+ const terminal = await openFileReceiver(page, "file-drop-remote");
+ const files = [
+ { name: "shot.png", type: "image/png", bytes: pngBytes(3) },
+ { name: "notes.txt", type: "text/plain", bytes: Buffer.from("remote notes") },
+ ];
+ try {
+ await dragFilesOver(page, files);
+ await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "1");
+ await expect(terminalDropOverlay(page)).toHaveText("Drop files here");
+
+ await dropFiles(page, files);
+ await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "0");
+
+ await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
+ await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
+ } finally {
+ await harness.killTerminal(terminal.id);
+ }
+ });
+
+ test("pastes dropped files' own paths when the host is this machine", async ({ page }) => {
+ const files = [
+ { name: "local-shot.png", type: "image/png", bytes: pngBytes(9) },
+ { name: "local-notes.txt", type: "text/plain", bytes: Buffer.from("local notes") },
+ ];
+ for (const file of files) {
+ await writeFile(path.join(harness.tempRepo.path, file.name), file.bytes);
+ }
+ await installLocalDesktopHost(page, harness.tempRepo.path);
+ const terminal = await openFileReceiver(page, "file-drop-local");
+ try {
+ await dropFiles(page, files);
+
+ await expectTerminalOutput(terminal.id, fileRead(pngBytes(9), "local"));
+ await expectTerminalOutput(terminal.id, fileRead(Buffer.from("local notes"), "local"));
+ expect(await terminalOutput(terminal.id)).not.toContain(":upload");
+ await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
+ } finally {
+ await harness.killTerminal(terminal.id);
+ }
+ });
+
+ test("lands image pastes in gesture order while typing stays live", async ({ page }) => {
+ const upload = await holdFileUploadResponse(page);
+ const terminal = await openFileReceiver(page, "image-order");
+ try {
+ await pasteImage(page, pngBytes(4));
+ await upload.waitUntilHeld();
+ await pasteImage(page, pngBytes(5));
+ await expect(page.getByTestId("terminal-file-uploading")).toHaveText("Uploading file…");
+
+ await typeLine(page, "describe");
+ await expectTerminalOutput(terminal.id, "TYPED:describe");
+
+ upload.release();
+ await expectTerminalOutput(terminal.id, fileRead(pngBytes(5), "upload"));
+ const output = await terminalOutput(terminal.id);
+ expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeGreaterThanOrEqual(0);
+ expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeLessThan(
+ output.indexOf(fileRead(pngBytes(5), "upload")),
+ );
+ await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
+ await expect(page.getByTestId("app-toast-message")).toHaveText("File uploaded");
+ } finally {
+ await harness.killTerminal(terminal.id);
+ }
+ });
+
+ test("Ctrl+C abandons an image paste that is still uploading", async ({ page }) => {
+ const upload = await holdFileUploadResponse(page);
+ const terminal = await openFileReceiver(page, "image-cancel");
+ try {
+ await pasteImage(page, pngBytes(6));
+ await upload.waitUntilHeld();
+
+ await terminalInput(page).press("Control+c");
+ await expectTerminalOutput(terminal.id, "INTERRUPTED");
+ await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
+ upload.release();
+
+ await pasteImage(page, pngBytes(7));
+ await expectTerminalOutput(terminal.id, fileRead(pngBytes(7), "upload"));
+ expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(6), "upload"));
+ } finally {
+ await harness.killTerminal(terminal.id);
+ }
+ });
+
+ test("a disconnect during upload reports one failure and input keeps working", async ({
+ page,
+ }) => {
+ const upload = await holdFileUploadResponse(page);
+ const terminal = await openFileReceiver(page, "image-disconnect");
+ try {
+ await pasteImage(page, pngBytes(8));
+ await upload.waitUntilHeld();
+
+ await upload.disconnect();
+ await expect(page.getByTestId("app-toast-message")).toHaveText(
+ "Failed to paste file: Host is not connected",
+ );
+
+ await typeLine(page, "after");
+ await expectTerminalOutput(terminal.id, "TYPED:after");
+ expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(8), "upload"));
+ } finally {
+ await harness.killTerminal(terminal.id);
+ }
+ });
});
diff --git a/packages/app/e2e/browser/workspace-model-restart.spec.ts b/packages/app/e2e/browser/workspace-model-restart.spec.ts
index e1dd703508..976e745549 100644
--- a/packages/app/e2e/browser/workspace-model-restart.spec.ts
+++ b/packages/app/e2e/browser/workspace-model-restart.spec.ts
@@ -404,7 +404,7 @@ async function fetchWorkspaceStatuses(
}
test.describe("Workspace model restart regressions", () => {
- test("browser-created same-cwd workspace preserves restarted agent status and migrated tab ownership", async ({
+ test("browser-created same-cwd workspace preserves repaired restart status and migrated tab ownership", async ({
page,
baseURL,
}) => {
@@ -423,9 +423,13 @@ test.describe("Workspace model restart regressions", () => {
.toMatchObject({
id: LEGACY_AGENT_ID,
workspaceId: seeded.workspaceA,
- status: "running",
+ status: "closed",
});
+ await expect
+ .poll(() => fetchWorkspaceStatuses(client, [seeded.workspaceA, seeded.workspaceB]))
+ .toEqual({ [seeded.workspaceA]: "done", [seeded.workspaceB]: "done" });
+
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await waitForSidebarHydration(page);
await expectWorkspaceRowDoesNotShowIndicator(page, {
@@ -472,15 +476,11 @@ test.describe("Workspace model restart regressions", () => {
]),
)
.toMatchObject({
+ [seeded.workspaceA]: "done",
[seeded.workspaceB]: "done",
[createdWorkspaceId]: "done",
});
- // The restarted provider session may settle while the browser creates the sibling. Its
- // initial running status is asserted above; this phase verifies that ownership never moves.
- const workspaceStatuses = await fetchWorkspaceStatuses(client, [seeded.workspaceA]);
- expect(["running", "done"]).toContain(workspaceStatuses[seeded.workspaceA]);
-
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: seeded.workspaceB,
diff --git a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts
index fdc0529451..7d8b484c77 100644
--- a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts
+++ b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts
@@ -11,6 +11,7 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
+import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -93,10 +94,20 @@ async function closeFirstVisibleDraftTab(page: Page): Promise {
await closeButton.first().click();
}
+async function openWorkspaceTabUrl(page: Page, route: string): Promise {
+ await page.evaluate((url) => {
+ window.history.pushState(null, "", url);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ }, route);
+ await expectAppRoute(page, route.split("?")[0]);
+}
+
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
- test("opens a notification's workspace on a different offline host", async ({ page }) => {
+ test("keeps an offline notification on the agent route until the host connects", async ({
+ page,
+ }) => {
const target = {
serverId: "notification-offline-host",
workspaceId: "notification-workspace",
@@ -123,14 +134,33 @@ test.describe("Workspace navigation regression", () => {
);
}, target);
- await expectAppRoute(
- page,
- buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
- { timeout: 30_000 },
- );
- await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
- await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
- await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
+ await expectAppRoute(page, buildHostAgentDetailRoute(target.serverId, target.agentId), {
+ timeout: 30_000,
+ });
+ await expect(page.getByTestId("agent-route-waiting-for-host")).toBeVisible();
+ await expect(page.getByText(/Notification Host/).first()).toBeVisible();
+ });
+
+ test("cold agent targets hydrate their workspace without opening the compact sidebar", async ({
+ page,
+ }) => {
+ const workspace = await seedWorkspace({ repoPrefix: "notification-cold-compact-" });
+ try {
+ const agent = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Cold notification agent",
+ });
+ await page.setViewportSize({ width: 390, height: 844 });
+ await gotoAppShell(page);
+ await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id));
+ await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId), {
+ timeout: 30_000,
+ });
+ await expectComposerVisible(page);
+ } finally {
+ await workspace.cleanup();
+ }
});
test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({
@@ -321,7 +351,7 @@ test.describe("Workspace navigation regression", () => {
}
});
- test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
+ test("a stale notification workspace preserves retained workspace selection and tabs", async ({
page,
}) => {
const serverId = getServerId();
@@ -435,7 +465,7 @@ test.describe("Workspace navigation regression", () => {
}),
);
},
- { agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
+ { agentId: secondAgent.id, serverId, workspaceId: firstWorkspace.workspaceId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
@@ -463,6 +493,22 @@ test.describe("Workspace navigation regression", () => {
await expect(secondDeckEntry).toBeHidden();
await expectWorkspaceDeckEntryCount(page, 2);
+ await expectAgentAbsentFromWorkspaceLayout(page, {
+ serverId,
+ workspaceId: firstWorkspace.workspaceId,
+ agentId: secondAgent.id,
+ });
+ await openWorkspaceTabUrl(
+ page,
+ buildHostWorkspaceOpenRoute(serverId, firstWorkspace.workspaceId, `agent:${firstAgent.id}`),
+ );
+ await expectWorkspaceTabVisible(page, firstAgent.id);
+ await expectAgentAbsentFromWorkspaceLayout(page, {
+ serverId,
+ workspaceId: secondWorkspace.workspaceId,
+ agentId: firstAgent.id,
+ });
+
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
diff --git a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts
index 4853e9daa4..909672c489 100644
--- a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts
+++ b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts
@@ -3,6 +3,7 @@ import { test, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
+import { revealSidebarProjectWorkspaces } from "../support/helpers/sidebar";
import {
expectSidebarWorkspaceSelected,
switchWorkspaceViaSidebar,
@@ -46,6 +47,7 @@ test("previous and next traverse every visible workspace while digit shortcuts s
}
await page.setViewportSize({ width: 1440, height: 1100 });
await gotoAppShell(page);
+ await revealSidebarProjectWorkspaces(page, project.projectKey);
const order = await readVisibleWorkspaceOrder(page);
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
@@ -68,7 +70,25 @@ test("previous and next traverse every visible workspace while digit shortcuts s
workspaceId: order[8],
});
});
+ await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
+ await expectHiddenWorkspaceShortcuts(page, order);
+ });
} finally {
await project.cleanup();
}
});
+
+async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) {
+ await page.getByLabel("Show less", { exact: true }).click();
+ const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
+ await expect(rows).toHaveCount(7);
+ await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
+ await page.keyboard.press("Alt+9");
+ await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
+ await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
+ 0,
+ );
+ await page.keyboard.press("Alt+]");
+ await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
+ await expect(rows).toHaveCount(7);
+}
diff --git a/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts b/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts
index 17c4d3dd45..969f330520 100644
--- a/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts
+++ b/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts
@@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
connectWorkspaceSetupClient,
+ setupCommandOutput,
createWorkspaceThroughDaemon,
openWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
@@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
- (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
+ (payload) =>
+ payload.status === "completed" &&
+ setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
diff --git a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts
index 227e2c8bc1..18a9704ddf 100644
--- a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts
+++ b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts
@@ -18,6 +18,7 @@ import {
} from "../support/helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
+ setupCommandOutput,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
@@ -92,7 +93,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
- payload.status === "completed" && payload.detail.log.includes("setup complete"),
+ payload.status === "completed" &&
+ setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -170,16 +172,19 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const initialRunning = waitForWorkspaceSetupProgress(
client,
- (payload) => payload.status === "running" && payload.detail.log === "",
+ (payload) => payload.status === "running" && payload.detail.commands.length === 0,
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
- (payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
+ (payload) =>
+ payload.status === "running" &&
+ setupCommandOutput(payload.detail).includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
- payload.status === "completed" && payload.detail.log.includes("setup complete"),
+ payload.status === "completed" &&
+ setupCommandOutput(payload.detail).includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
@@ -192,8 +197,8 @@ test.describe("Workspace setup streaming", () => {
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
- expect(runningPayload.detail.log).toContain("starting setup");
- expect(completedPayload.detail.log).toContain("setup complete");
+ expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup");
+ expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete");
expect(completedPayload.error).toBeNull();
} finally {
await client.close();
@@ -215,7 +220,9 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
- (payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
+ (payload) =>
+ payload.status === "failed" &&
+ setupCommandOutput(payload.detail).includes("setup failed"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
@@ -224,8 +231,8 @@ test.describe("Workspace setup streaming", () => {
});
const failedPayload = await failed;
- expect(failedPayload.detail.log).toContain("starting setup");
- expect(failedPayload.detail.log).toContain("setup failed");
+ expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup");
+ expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
await openHomeWithProject(page, repo.path);
@@ -331,7 +338,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
- payload.status === "completed" && payload.detail.log.includes("setup complete"),
+ payload.status === "completed" &&
+ setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -377,7 +385,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
- payload.status === "completed" && payload.detail.log.includes("setup complete"),
+ payload.status === "completed" &&
+ setupCommandOutput(payload.detail).includes("setup complete"),
);
const result = await client.createPaseoWorktree({
diff --git a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad
index c94e24914d..359e89c5c9 100644
--- a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad
+++ b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad
@@ -2,7 +2,8 @@ context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
-# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms).
+# unselected PINNED workspace row. Ordinary workspace rows are not draggable.
+# Hold past drag arming (180ms), then release.
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh
index 49c907dba9..809483ceff 100755
--- a/packages/app/e2e/mobile/composer-keyboard/android.sh
+++ b/packages/app/e2e/mobile/composer-keyboard/android.sh
@@ -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"
diff --git a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs
index 6bd13a5da5..637a162682 100644
--- a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs
+++ b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs
@@ -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 = {
diff --git a/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs b/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs
new file mode 100644
index 0000000000..22c0b42e8f
--- /dev/null
+++ b/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs
@@ -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,
+ };
+}
diff --git a/packages/app/e2e/mobile/composer-keyboard/ios.mjs b/packages/app/e2e/mobile/composer-keyboard/ios.mjs
new file mode 100644
index 0000000000..e3fc4af072
--- /dev/null
+++ b/packages/app/e2e/mobile/composer-keyboard/ios.mjs
@@ -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`);
diff --git a/packages/app/e2e/mobile/sidebar-system-insets/android.mjs b/packages/app/e2e/mobile/sidebar-system-insets/android.mjs
new file mode 100644
index 0000000000..cf7c45fbb0
--- /dev/null
+++ b/packages/app/e2e/mobile/sidebar-system-insets/android.mjs
@@ -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
+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}`);
+}
diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts
index 9608a95a0c..c6d3579206 100644
--- a/packages/app/e2e/support/creation-fixtures.ts
+++ b/packages/app/e2e/support/creation-fixtures.ts
@@ -35,7 +35,8 @@ export const test = base.extend<{
import { readFile, rm } from "node:fs/promises";
export default function contribute(server) {
return server.before("agent.session_open", async ({ request }) => {
- if (request.reason !== "create") return request;
+ // Metadata helpers have no workspace and must not consume the one-shot failure.
+ if (request.reason !== "create" || !request.workspaceId) return request;
const gate = ${JSON.stringify(gate)};
let command = await readFile(gate, "utf8").catch(() => "release");
while (command === "hold") {
diff --git a/packages/app/e2e/support/helpers/command-center-agent-controls.ts b/packages/app/e2e/support/helpers/command-center-agent-controls.ts
index c460154d25..f819cc98e9 100644
--- a/packages/app/e2e/support/helpers/command-center-agent-controls.ts
+++ b/packages/app/e2e/support/helpers/command-center-agent-controls.ts
@@ -18,10 +18,29 @@ export async function openCommandCenterForAgent(
workspace: Pick,
): Promise {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
+ await expect(
+ page.getByRole("button", { name: /^Select agent mode \(/ }).filter({ visible: true }),
+ ).toBeVisible({ timeout: 30_000 });
const panel = await openCommandCenter(page);
return panel.getByTestId("command-center-input");
}
+/**
+ * Types a query that matches both the agent's modes and its models, and waits until rows from both
+ * groups are listed. The agent's controls register after the panel opens; a key pressed before
+ * then lands on whatever else matched, such as file search.
+ */
+export async function searchAgentControls(page: Page, query: string): Promise {
+ const panel = page.getByTestId("command-center-panel");
+ await panel.getByTestId("command-center-input").fill(query);
+ await expect(panel.getByRole("button", { name: /^Mode › / }).first()).toBeVisible({
+ timeout: 30_000,
+ });
+ await expect(panel.getByRole("button", { name: /^Model › / }).first()).toBeVisible({
+ timeout: 30_000,
+ });
+}
+
export async function chooseCommandCenterAgentControl(input: {
page: Page;
query: string;
diff --git a/packages/app/e2e/support/helpers/command-center-file-search.ts b/packages/app/e2e/support/helpers/command-center-file-search.ts
index ec6ade4102..eb0b888d24 100644
--- a/packages/app/e2e/support/helpers/command-center-file-search.ts
+++ b/packages/app/e2e/support/helpers/command-center-file-search.ts
@@ -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();
+ },
+ };
+}
diff --git a/packages/app/e2e/support/helpers/command-center-performance.ts b/packages/app/e2e/support/helpers/command-center-performance.ts
new file mode 100644
index 0000000000..b61771c05e
--- /dev/null
+++ b/packages/app/e2e/support/helpers/command-center-performance.ts
@@ -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((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"));
+ });
+}
diff --git a/packages/app/e2e/support/helpers/command-center-scroll.ts b/packages/app/e2e/support/helpers/command-center-scroll.ts
index dd4712fb36..42287ca693 100644
--- a/packages/app/e2e/support/helpers/command-center-scroll.ts
+++ b/packages/app/e2e/support/helpers/command-center-scroll.ts
@@ -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 {
+ return panel.getByTestId("command-center-results").evaluate(async (viewport) => {
+ const samples: number[] = [];
+ for (let frame = 0; frame < 30; frame += 1) {
+ await new Promise((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 });
+}
diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts
index 9098068698..1c3d5dda89 100644
--- a/packages/app/e2e/support/helpers/creation.ts
+++ b/packages/app/e2e/support/helpers/creation.ts
@@ -17,6 +17,7 @@ import {
workspaceDeckEntryLocator,
} from "./workspace-ui";
import { getServerId } from "./server-id";
+import { revealSidebarProjectWorkspaces } from "./sidebar";
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
@@ -282,6 +283,7 @@ export async function createCreationScenario(page: Page) {
});
}
await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0);
+ await revealSidebarProjectWorkspaces(page, project.projectKey);
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId });
await page
.getByTestId(draftTestId!)
diff --git a/packages/app/e2e/support/helpers/file-upload-gate.ts b/packages/app/e2e/support/helpers/file-upload-gate.ts
new file mode 100644
index 0000000000..c33b49f637
--- /dev/null
+++ b/packages/app/e2e/support/helpers/file-upload-gate.ts
@@ -0,0 +1,44 @@
+import type { Page, WebSocketRoute } from "@playwright/test";
+import { daemonWsRoutePattern } from "./daemon-port";
+
+type WebSocketMessage = string | Buffer;
+
+function isFileUploadResponse(message: WebSocketMessage): boolean {
+ if (typeof message !== "string") return false;
+ try {
+ const envelope = JSON.parse(message) as { type?: unknown; message?: { type?: unknown } };
+ return (envelope.message ?? envelope).type === "file.upload.response";
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Holds the daemon's first `file.upload.response`, so the upload is stored on
+ * the host but the app still sees it in flight.
+ */
+export async function holdFileUploadResponse(page: Page) {
+ let held: { browser: WebSocketRoute; message: WebSocketMessage } | null = null;
+ let resolveHeld: (() => void) | null = null;
+ const heldResponse = new Promise((resolve) => {
+ resolveHeld = resolve;
+ });
+ await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
+ const server = browser.connectToServer();
+ browser.onMessage((message) => server.send(message));
+ server.onMessage((message) => {
+ if (held || !isFileUploadResponse(message)) {
+ browser.send(message);
+ return;
+ }
+ held = { browser, message };
+ resolveHeld?.();
+ });
+ });
+ return {
+ waitUntilHeld: () => heldResponse,
+ release: () => held?.browser.send(held.message),
+ /** Drops the app's connection while the upload is in flight. */
+ disconnect: () => held?.browser.close({ code: 1001, reason: "Upload gate disconnect" }),
+ };
+}
diff --git a/packages/app/e2e/support/helpers/local-desktop-host.ts b/packages/app/e2e/support/helpers/local-desktop-host.ts
new file mode 100644
index 0000000000..863857e947
--- /dev/null
+++ b/packages/app/e2e/support/helpers/local-desktop-host.ts
@@ -0,0 +1,38 @@
+import type { Page } from "@playwright/test";
+
+// Poses as the desktop app running the e2e daemon as its own managed daemon, so
+// `useIsLocalDaemon` reports the host as this machine. `directory` is where the
+// desktop bridge claims dropped files live on disk.
+export async function installLocalDesktopHost(page: Page, directory: string): Promise {
+ const serverId = process.env.E2E_SERVER_ID;
+ if (!serverId) {
+ throw new Error("E2E_SERVER_ID is not set");
+ }
+ await page.addInitScript(
+ (localHost: { serverId: string; directory: string }) => {
+ (window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
+ platform: "linux",
+ invoke: async (command: string) => {
+ if (command === "desktop_daemon_status") {
+ return {
+ serverId: localHost.serverId,
+ status: "running",
+ listen: null,
+ hostname: null,
+ pid: null,
+ home: "",
+ version: null,
+ desktopManaged: true,
+ error: null,
+ };
+ }
+ return null;
+ },
+ getPendingOpenProject: async () => null,
+ events: { on: async () => () => undefined },
+ webUtils: { getPathForFile: (file: File) => `${localHost.directory}/${file.name}` },
+ };
+ },
+ { serverId, directory },
+ );
+}
diff --git a/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts b/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts
index 9cf67326be..29ae71d84d 100644
--- a/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts
+++ b/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts
@@ -11,7 +11,6 @@ export const MARKDOWN_PANEL_PLUGIN_ID = "file-panel-picker-e2e";
export const MARKDOWN_PANEL_ID = `plugin:${MARKDOWN_PANEL_PLUGIN_ID}/rich-markdown`;
export const BUILTIN_MARKDOWN_OPTION = "Markdown — Paseo";
export const PLUGIN_MARKDOWN_OPTION = `Rich Markdown — ${MARKDOWN_PANEL_PLUGIN_ID}`;
-export const SOURCE_OPTION = "Source — Paseo";
const SOURCE = `import React from "react";
import { Text, View } from "react-native";
diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts
index f3279ce7ef..2418f284d2 100644
--- a/packages/app/e2e/support/helpers/plugin-buttons.ts
+++ b/packages/app/e2e/support/helpers/plugin-buttons.ts
@@ -1,11 +1,17 @@
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
-import type { Locator } from "@playwright/test";
+import type { Locator, TestInfo } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
+import {
+ closeCompactWorkspaceMenu,
+ compactWorkspaceMenu,
+ goBackInMenu,
+ openCompactWorkspaceMenu,
+} from "./compact-workspace-scripts";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { pluginRequirements } from "./plugin-fixture";
@@ -60,7 +66,7 @@ export default function contribute(client) {
let finishReview = () => {};
let attempts = 0;
const action = { kind: "action", async onPress() {
- if (++attempts === 1) throw new Error("Deployment failed. Try again.");
+ if (++attempts % 2 === 1) throw new Error("Deployment failed. Try again.");
await new Promise((resolve) => { finish = resolve; });
deploy.update({ label: "Deployed" });
} };
@@ -165,6 +171,13 @@ async function command(page: Page, title: string) {
await expect(panel).not.toBeVisible();
}
+/** The command center opens from the sidebar, which compact layouts keep closed. */
+async function commandFromCompact(page: Page, title: string) {
+ await page.setViewportSize(WIDE);
+ await command(page, title);
+ await page.setViewportSize(COMPACT);
+}
+
async function expectSurfaceVisible(
page: Page,
subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
@@ -176,6 +189,61 @@ async function expectDetails(page: Page) {
await expect(page.getByText("All systems operational", { exact: true })).toBeInViewport();
}
+function workspaceMenuRow(page: Page, title: string): Locator {
+ return compactWorkspaceMenu(page).getByRole("menuitem", { name: title, exact: true });
+}
+
+async function expectWorkspaceMenuRows(page: Page, titles: readonly string[]) {
+ const rows = compactWorkspaceMenu(page).getByRole("menuitem");
+ for (const [index, title] of titles.entries()) {
+ await expect(rows.nth(index)).toHaveAccessibleName(title);
+ }
+}
+
+async function chooseInWorkspaceMenu(page: Page, title: string) {
+ await workspaceMenuRow(page, title).click();
+}
+
+async function expectWorkspaceMenuClosed(page: Page) {
+ await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
+}
+
+async function expectCompactDetails(page: Page) {
+ const menu = compactWorkspaceMenu(page);
+ await expect(menu.getByText("All systems operational", { exact: true })).toBeInViewport();
+ await expect(menu.getByText("Presentation: compact", { exact: true })).toBeVisible();
+}
+
+/** Resolves once `subject` renders the same pixels twice in a row: entering animations are done. */
+async function expectStill(subject: Locator) {
+ let previous: Buffer | null = null;
+ await expect
+ .poll(
+ async () => {
+ const current = await subject.screenshot();
+ const still = previous !== null && current.equals(previous);
+ previous = current;
+ return still;
+ },
+ { intervals: [150] },
+ )
+ .toBe(true);
+}
+
+/** Attaches the screen once per colour scheme, for review alongside the run. */
+async function captureSchemes(page: Page, info: TestInfo, name: string, subject: Locator) {
+ for (const colorScheme of ["light", "dark"] as const) {
+ await page.emulateMedia({ colorScheme });
+ await expectStill(subject);
+ const file = info.outputPath(`${name}-${colorScheme}.png`);
+ await info.attach(`${name}-${colorScheme}`, {
+ body: await page.screenshot({ path: file }),
+ contentType: "image/png",
+ });
+ }
+ await page.emulateMedia({ colorScheme: "light" });
+}
+
async function expectHeaderTextTruncated(page: Page) {
const title = page.getByTestId("workspace-header-title");
const subtitle = page.getByTestId("workspace-header-subtitle");
@@ -204,10 +272,12 @@ async function expectHeaderTextTruncated(page: Page) {
export async function withButtonShowcase(
page: Page,
+ info: TestInfo,
run: (buttons: {
openWideMenusAndPopovers(): Promise;
runAndUpdateActions(): Promise;
openCompactSheets(): Promise;
+ useCompactWorkspaceMenu(): Promise;
hideAndUnloadOpenButtons(): Promise;
verifyObservationLifetime(): Promise;
}) => Promise,
@@ -284,6 +354,12 @@ export async function withButtonShowcase(
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await expectSurfaceVisible(page);
+ await captureSchemes(
+ page,
+ info,
+ "desktop-wide-header",
+ page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
+ );
await press(page, "Header checks");
await expect(
page.getByRole("menuitem", { name: "Run checks", exact: true }),
@@ -303,6 +379,12 @@ export async function withButtonShowcase(
await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
+ await captureSchemes(
+ page,
+ info,
+ "desktop-overflow",
+ page.getByRole("menuitem", { name: "Production locked", exact: true }),
+ );
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Production locked", exact: true }),
@@ -372,7 +454,19 @@ export async function withButtonShowcase(
await expectSurfaceVisible(page);
await command(page, "Enable example buttons");
await command(page, "Use icon-only header");
+ await expect(
+ page.getByRole("button", { name: "Deploy application", exact: true }),
+ ).toHaveText("");
await expectSurfaceVisible(page);
+ await expect(
+ page.getByText("Deployment failed. Try again.", { exact: true }),
+ ).toHaveCount(0);
+ await captureSchemes(
+ page,
+ info,
+ "desktop-icon-only-header",
+ page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
+ );
await command(page, "Hide review button");
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
0,
@@ -383,7 +477,7 @@ export async function withButtonShowcase(
);
}),
openCompactSheets: () =>
- test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
+ test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
@@ -422,6 +516,98 @@ export async function withButtonShowcase(
);
await choose(page, "Run checks");
}),
+ useCompactWorkspaceMenu: () =>
+ test.step("compact workspace menu carries header buttons with their actions, menus, and content", async () => {
+ await openCompactWorkspaceMenu(page);
+ await expectWorkspaceMenuRows(page, [
+ "Deploy application",
+ "Header checks",
+ "Header status",
+ "Open deployment logs",
+ "Production locked",
+ "Copy workspace path",
+ ]);
+ await expect(workspaceMenuRow(page, "Production locked")).toBeDisabled();
+ // Deploy lost its label to "Use icon-only header" and logs never had one; rows read titles.
+ await expect(workspaceMenuRow(page, "Deploy application")).toHaveText(
+ "Deploy application",
+ );
+ await expect(workspaceMenuRow(page, "Open deployment logs")).toHaveText(
+ "Open deployment logs",
+ );
+ await captureSchemes(page, info, "compact-root-icon-only", compactWorkspaceMenu(page));
+
+ await showcase.setTitle("Compact checks");
+ await expect(workspaceMenuRow(page, "Deploy application")).toHaveCount(0);
+ await showcase.setTitle("Button showcase");
+ await expect(workspaceMenuRow(page, "Deploy application")).toBeVisible();
+
+ await chooseInWorkspaceMenu(page, "Deploy application");
+ await expectWorkspaceMenuClosed(page);
+ await expect(
+ page.getByText("Deployment failed. Try again.", { exact: true }),
+ ).toBeVisible();
+ await openCompactWorkspaceMenu(page);
+ await chooseInWorkspaceMenu(page, "Deploy application");
+ await expectWorkspaceMenuClosed(page);
+ await openCompactWorkspaceMenu(page);
+ await expect(workspaceMenuRow(page, "Deploy application")).toBeDisabled();
+ await captureSchemes(page, info, "compact-row-states", compactWorkspaceMenu(page));
+ await closeCompactWorkspaceMenu(page);
+ await commandFromCompact(page, "Finish deployment");
+ await openCompactWorkspaceMenu(page);
+ await expect(workspaceMenuRow(page, "Deploy application")).toBeEnabled();
+ await captureSchemes(page, info, "compact-root", compactWorkspaceMenu(page));
+
+ await chooseInWorkspaceMenu(page, "Header checks");
+ await expectWorkspaceMenuRows(page, ["Run checks", "Environment", "Deployment details"]);
+ await captureSchemes(page, info, "compact-header-checks", compactWorkspaceMenu(page));
+ await goBackInMenu(page);
+ await expect(workspaceMenuRow(page, "Header status")).toBeVisible();
+ await chooseInWorkspaceMenu(page, "Header checks");
+ await chooseInWorkspaceMenu(page, "Environment");
+ await expectWorkspaceMenuRows(page, ["Staging", "Production"]);
+ await expect(workspaceMenuRow(page, "Production")).toBeDisabled();
+ await captureSchemes(page, info, "compact-environment", compactWorkspaceMenu(page));
+ await goBackInMenu(page);
+ await expect(workspaceMenuRow(page, "Run checks")).toBeVisible();
+ await goBackInMenu(page);
+ await expect(workspaceMenuRow(page, "Header checks")).toBeVisible();
+
+ await chooseInWorkspaceMenu(page, "Header checks");
+ await chooseInWorkspaceMenu(page, "Deployment details");
+ await expectCompactDetails(page);
+ await captureSchemes(
+ page,
+ info,
+ "compact-deployment-details",
+ compactWorkspaceMenu(page),
+ );
+ await goBackInMenu(page);
+ await expect(workspaceMenuRow(page, "Run checks")).toBeVisible();
+ await chooseInWorkspaceMenu(page, "Deployment details");
+ await expectCompactDetails(page);
+ await press(page, "Close deployment details");
+ await expectWorkspaceMenuClosed(page);
+
+ await openCompactWorkspaceMenu(page);
+ await chooseInWorkspaceMenu(page, "Header status");
+ await expectCompactDetails(page);
+ await captureSchemes(page, info, "compact-header-status", compactWorkspaceMenu(page));
+ await press(page, "Fail button content");
+ await expect(
+ compactWorkspaceMenu(page).getByText("Plugin failed: Button content failed", {
+ exact: true,
+ }),
+ ).toBeVisible();
+ await goBackInMenu(page);
+ await expect(workspaceMenuRow(page, "Header status")).toBeVisible();
+ await chooseInWorkspaceMenu(page, "Open deployment logs");
+ await expectCompactDetails(page);
+ await captureSchemes(page, info, "compact-logs-popover", compactWorkspaceMenu(page));
+ await press(page, "Close deployment details");
+ await expectWorkspaceMenuClosed(page);
+ }),
hideAndUnloadOpenButtons: () =>
test.step("hiding an open button and unloading a plugin remove its surfaces", async () => {
await page.setViewportSize(WIDE);
@@ -454,6 +640,12 @@ export async function withButtonShowcase(
0,
);
await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0);
+ await page.setViewportSize(COMPACT);
+ await openCompactWorkspaceMenu(page);
+ await expect(workspaceMenuRow(page, "Header checks")).toHaveCount(0);
+ await expect(workspaceMenuRow(page, "Header status")).toHaveCount(0);
+ await closeCompactWorkspaceMenu(page);
+ await page.setViewportSize(WIDE);
await showcase.setTitle("Healthy app after plugin unload");
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Healthy app after plugin unload",
diff --git a/packages/app/e2e/support/helpers/plugin-translations.ts b/packages/app/e2e/support/helpers/plugin-translations.ts
new file mode 100644
index 0000000000..e88c6be31b
--- /dev/null
+++ b/packages/app/e2e/support/helpers/plugin-translations.ts
@@ -0,0 +1,164 @@
+import { hideDeveloperToasts } from "../developer-ui.mjs";
+import { seedWorkspace } from "./seed-client";
+import { buildAgentRoute } from "./mock-agent";
+import { openCommandCenter } from "./command-center";
+import type { TestInfo, BrowserContext } from "@playwright/test";
+import { expect, type Page } from "../fixtures";
+import { gotoAppShell, openSettings } from "./app";
+import { connectNewWorkspaceDaemonClient } from "./new-workspace";
+import { copyPluginExample } from "./plugin-fixture";
+
+async function chooseChinese(page: Page) {
+ await openSettings(page);
+ await page.getByRole("button", { name: "System", exact: true }).click();
+ await page.getByRole("menuitem", { name: "简体中文 - Simplified Chinese", exact: true }).click();
+ await page.goBack();
+}
+
+async function chooseEnglish(page: Page) {
+ await page.getByTestId("sidebar-settings").click();
+ await page.getByRole("button", { name: "简体中文", exact: true }).click();
+ await page.getByRole("menuitem", { name: "English - 英语", exact: true }).click();
+ await page.goBack();
+}
+
+async function capture(page: Page, info: TestInfo, name: string) {
+ const file = info.outputPath(`${name}.png`);
+ await page.screenshot({ path: file, animations: "disabled" });
+ await info.attach(name, { path: file, contentType: "image/png" });
+}
+
+export async function verifyPluginTranslations(page: Page, info: TestInfo) {
+ const example = await copyPluginExample("translations");
+ const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
+ const config = await client.getDaemonConfig();
+ const browser = page.context().browser();
+ if (!browser) throw new Error("Translation isolation requires a browser");
+ let secondContext: BrowserContext | undefined;
+ try {
+ await client.patchDaemonConfig({ pluginsEnabled: true });
+ await client.installDirectoryPlugin(example.directory);
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await gotoAppShell(page);
+ await page.getByRole("button", { name: "Workspace notes", exact: true }).click();
+ await expect(page.getByText("App language: en", { exact: true })).toBeVisible();
+ await page
+ .getByRole("textbox", { name: "Your draft", exact: true })
+ .fill("Remember the language switch");
+ await capture(page, info, "01-english-draft");
+ await page.getByRole("button", { name: "Preview draft", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Close preview", exact: true })).toBeVisible();
+ await capture(page, info, "02-english-preview");
+ await page.getByRole("button", { name: "Close preview", exact: true }).click();
+ secondContext = await browser.newContext({
+ storageState: await page.context().storageState(),
+ viewport: { width: 1440, height: 1000 },
+ });
+ const second = await secondContext.newPage();
+ await hideDeveloperToasts(second);
+ await second.goto(page.url());
+ await expect(second.getByText("App language: en", { exact: true })).toBeVisible();
+ await chooseChinese(second);
+ await expect(second.getByText("应用语言:zh-CN", { exact: true })).toBeVisible();
+ await second
+ .getByRole("textbox", { name: "你的草稿", exact: true })
+ .fill("A separate client draft");
+ await expect(page.getByText("App language: en", { exact: true })).toBeVisible();
+ await expect(page.getByTestId("plugin-surface-title")).toHaveText("Workspace notes");
+ await capture(second, info, "03-independent-chinese-client");
+ await chooseChinese(page);
+ await expect(page.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue(
+ "Remember the language switch",
+ );
+ await expect(page.getByTestId("plugin-surface-title")).toHaveText("工作区笔记");
+ await expect(page.getByText("应用语言:zh-CN", { exact: true })).toBeVisible();
+ await expect(
+ page.getByText("This English-only message demonstrates fallback.", { exact: true }),
+ ).toBeVisible();
+ await capture(page, info, "04-chinese-draft-preserved");
+ await page.getByRole("button", { name: "预览草稿", exact: true }).click();
+ await expect(page.getByText("草稿预览", { exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "关闭预览", exact: true })).toBeVisible();
+ await capture(page, info, "05-chinese-preview");
+ await page.getByRole("button", { name: "关闭预览", exact: true }).click();
+ await chooseEnglish(page);
+ await expect(page.getByRole("textbox", { name: "Your draft", exact: true })).toHaveValue(
+ "Remember the language switch",
+ );
+ await expect(page.getByTestId("plugin-surface-title")).toHaveText("Workspace notes");
+ await expect(second.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue(
+ "A separate client draft",
+ );
+ await capture(page, info, "06-return-to-english-draft-preserved");
+ await page.getByRole("button", { name: "Add note", exact: true }).click();
+ await expect(page.getByText("1 note", { exact: true })).toBeVisible();
+ await page.getByRole("textbox", { name: "Your draft", exact: true }).fill("Second note");
+ await page.getByRole("button", { name: "Add note", exact: true }).click();
+ await expect(page.getByText("2 notes", { exact: true })).toBeVisible();
+ } finally {
+ await secondContext?.close().catch(() => undefined);
+ await client.removePlugin("translation-notes").catch(() => undefined);
+ await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
+ await client.close();
+ await example.cleanup();
+ }
+}
+
+export async function verifyPluginToolTranslations(page: Page, info: TestInfo) {
+ const workspace = await seedWorkspace({
+ repoPrefix: "translation-tools-",
+ title: "Notes translation QA",
+ });
+ const agent = await workspace.client.createAgent({
+ provider: "mock",
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Notes",
+ model: "ten-second-stream",
+ modeId: "load-test",
+ });
+ const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
+ const config = await client.getDaemonConfig();
+ const example = await copyPluginExample("translations");
+ try {
+ await client.patchDaemonConfig({ pluginsEnabled: true });
+ await client.installDirectoryPlugin(example.directory);
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await gotoAppShell(page);
+ await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
+ const commandCenter = await openCommandCenter(page);
+ await commandCenter.getByTestId("command-center-input").fill("Show notes tools");
+ await commandCenter.getByRole("button", { name: "Show notes tools", exact: true }).click();
+ const tools = page
+ .getByRole("toolbar", { name: "Workspace actions", exact: true })
+ .getByRole("button", { name: "Notes tools", exact: true });
+ await tools.hover();
+ await expect(page.getByRole("tooltip")).toContainText("Notes tools");
+ await capture(page, info, "07-english-tooltip");
+ await tools.click();
+ await page.getByRole("menuitem", { name: "Open notes panel", exact: true }).click();
+ await page
+ .getByRole("textbox", { name: "Your draft", exact: true })
+ .fill("A workspace panel draft");
+ await chooseChinese(page);
+ await expect(page.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue(
+ "A workspace panel draft",
+ );
+ const translatedTools = page
+ .getByRole("toolbar")
+ .getByRole("button", { name: "笔记工具", exact: true });
+ await translatedTools.hover();
+ await expect(page.getByRole("tooltip")).toContainText("笔记工具");
+ await capture(page, info, "08-chinese-tooltip-and-panel-draft");
+ await translatedTools.click();
+ await expect(page.getByRole("menuitem", { name: "打开笔记面板", exact: true })).toBeVisible();
+ await capture(page, info, "09-chinese-menu");
+ await page.keyboard.press("Escape");
+ } finally {
+ await client.removePlugin("translation-notes").catch(() => undefined);
+ await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
+ await client.close();
+ await example.cleanup();
+ await workspace.cleanup();
+ }
+}
diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts
index 4aec3fff01..4419e5193f 100644
--- a/packages/app/e2e/support/helpers/seed-client.ts
+++ b/packages/app/e2e/support/helpers/seed-client.ts
@@ -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;
setWorkspaceLabel(input: {
@@ -147,6 +148,7 @@ export interface SeedDaemonClient {
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
+ cancelAgent(agentId: string): Promise;
sendAgentMessage(agentId: string, text: string): Promise;
waitForFinish(
agentId: string,
diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts
index 5b7b819bf0..66a913e7b3 100644
--- a/packages/app/e2e/support/helpers/sidebar.ts
+++ b/packages/app/e2e/support/helpers/sidebar.ts
@@ -1,5 +1,6 @@
import { expect, type Page } from "@playwright/test";
import { getServerId } from "./server-id";
+import { projectEquivalenceViewKey } from "./project-view-key";
interface ContextMenuAnchor {
x: number;
@@ -295,3 +296,12 @@ export async function expectMobileExplorerHidden(page: Page): Promise {
timeout: 5_000,
});
}
+
+export async function revealSidebarProjectWorkspaces(
+ page: Page,
+ projectKey: string,
+): Promise {
+ await page
+ .getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(projectKey)}`)
+ .click();
+}
diff --git a/packages/app/e2e/support/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts
index f3a5ca7c71..3354bf3dd8 100644
--- a/packages/app/e2e/support/helpers/subagents.ts
+++ b/packages/app/e2e/support/helpers/subagents.ts
@@ -1,3 +1,7 @@
+import { connectDaemonClient } from "./daemon-client-loader";
+import { startIsolatedHostDaemon } from "./isolated-host-daemon";
+import { seedWorkspace } from "./seed-client";
+import { getE2EDaemonPort } from "./daemon-port";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
@@ -166,7 +170,7 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
}
export async function expectManagedSubagentUnarchived(
- workspace: Pick,
+ workspace: { client: Pick },
childId: string,
): Promise {
await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({
@@ -175,7 +179,7 @@ export async function expectManagedSubagentUnarchived(
}
export async function expectManagedSubagentArchived(
- workspace: Pick,
+ workspace: { client: Pick },
childId: string,
): Promise {
await expect
@@ -320,3 +324,150 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom
await expect(detachButton).toBeVisible({ timeout: 30_000 });
await detachButton.click();
}
+
+/** The primary children come from the real agent MCP endpoint on A. */
+export async function delegateFleetThroughMcp() {
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
+ const { StreamableHTTPClientTransport } =
+ await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
+ const { z } = await import("zod");
+ type DaemonClient = import("@getpaseo/client/internal/daemon-client").DaemonClient;
+ const workspace = await seedWorkspace({ repoPrefix: "fleet-mcp-" });
+ const destination = await startIsolatedHostDaemon(`fleet_${crypto.randomUUID()}`);
+ const a = await connectDaemonClient({ clientIdPrefix: "fleet-seed-A" });
+ const b = await connectDaemonClient({
+ clientIdPrefix: "fleet-observe-B",
+ port: destination.port,
+ });
+ const serverId = a.getLastServerInfoMessage()!.serverId;
+ const mcp = new Client({ name: "fleet-parent", version: "1" });
+ const close = async () => {
+ await mcp.close();
+ await Promise.allSettled([a.close(), b.close()]);
+ await destination.close();
+ await workspace.cleanup();
+ };
+ const call = async (name: string, args: Record = {}) => {
+ const response = await mcp.callTool({ name, arguments: args });
+ expect(response, name).not.toMatchObject({ isError: true });
+ return z.record(z.string(), z.unknown()).parse(response.structuredContent);
+ };
+ try {
+ const parent = await a.createAgent({
+ provider: "mock",
+ model: "e2e-fast-stream",
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Fleet parent",
+ modeId: "load-test",
+ });
+ const registration = await a.addHost({
+ connection: `127.0.0.1:${destination.port}`,
+ label: "Fleet B",
+ });
+ await expect
+ .poll(
+ async () =>
+ (await a.listHosts()).hosts.find(
+ (row) => row.registrationId === registration.registrationId,
+ )?.status,
+ )
+ .toBe("online");
+ await mcp.connect(
+ new StreamableHTTPClientTransport(
+ new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents?callerAgentId=${parent.id}`),
+ ),
+ );
+ expect(await call("list_hosts")).toMatchObject({
+ hosts: expect.arrayContaining([
+ expect.objectContaining({ serverId: destination.serverId, label: "Fleet B" }),
+ ]),
+ });
+ const target = await call("create_workspace", {
+ serverId: destination.serverId,
+ isolation: "local",
+ path: workspace.repoPath,
+ title: "B workspace",
+ });
+ expect(await call("list_workspaces", { serverId: destination.serverId })).toMatchObject({
+ workspaces: expect.arrayContaining([
+ expect.objectContaining({ workspaceId: target.workspaceId }),
+ ]),
+ });
+ const createChild = async (targetServerId: string, workspaceId: string, title: string) => {
+ const result = await call("create_agent", {
+ serverId: targetServerId,
+ workspaceId,
+ title,
+ provider: "mock/e2e-fast-stream",
+ settings: { modeId: "load-test" },
+ initialPrompt: `Finish ${title}`,
+ });
+ return {
+ serverId: targetServerId,
+ agentId: z.string().parse(result.agentId),
+ workspaceId,
+ title,
+ };
+ };
+ const local = await createChild(serverId, workspace.workspaceId, "MCP child on A");
+ const remote = await createChild(
+ destination.serverId,
+ z.string().parse(target.workspaceId),
+ "MCP child on B",
+ );
+ // Existing SDK reservation creates unrelated equal-ID fixtures; primary children remain MCP-created.
+ await b.createAgent({
+ agentId: local.agentId,
+ provider: "mock",
+ model: "e2e-fast-stream",
+ cwd: workspace.repoPath,
+ workspaceId: String(target.workspaceId),
+ title: "Unrelated on B",
+ modeId: "load-test",
+ });
+ await a.createAgent({
+ agentId: remote.agentId,
+ provider: "mock",
+ model: "e2e-fast-stream",
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Unrelated on A",
+ modeId: "load-test",
+ });
+ return {
+ workspace,
+ destination,
+ parent,
+ serverId,
+ local,
+ remote,
+ a,
+ b,
+ close,
+ async expectCompletion(child: typeof local) {
+ await expect
+ .poll(async () => {
+ const timeline = await a.fetchAgentTimeline(parent.id, {
+ direction: "tail",
+ limit: 100,
+ });
+ return timeline.entries
+ .flatMap(({ item }) =>
+ item.type === "tool_call" && item.agentMessage ? [item.agentMessage] : [],
+ )
+ .filter(
+ (message) =>
+ message.event === "finished" &&
+ message.sender.id === child.agentId &&
+ message.text.includes(`Server: ${child.serverId}`),
+ ).length;
+ })
+ .toBe(1);
+ },
+ };
+ } catch (error) {
+ await close();
+ throw error;
+ }
+}
diff --git a/packages/app/e2e/support/helpers/terminal-perf.ts b/packages/app/e2e/support/helpers/terminal-perf.ts
index ac41b7343b..5c00bed485 100644
--- a/packages/app/e2e/support/helpers/terminal-perf.ts
+++ b/packages/app/e2e/support/helpers/terminal-perf.ts
@@ -68,8 +68,8 @@ export async function navigateToTerminal(
const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId);
await page.goto(workspaceRoute);
- // The workspace layout consumes `?open=...`, returns null during the effect,
- // then replaces the URL with the clean workspace route after preparing the tab.
+ // The route seeds the legacy URL target into the pending-open model, then
+ // removes the query. Tab readiness below waits for that intent to be consumed.
// On CI, Expo Router's rootNavigationState may take time to initialize,
// so we allow a generous timeout here.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
@@ -86,7 +86,8 @@ export async function navigateToTerminal(
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
await terminalTab.click();
- const terminalSurface = page.locator('[data-testid="terminal-surface"]');
+ // Inactive terminal tabs retain their surfaces; interact with the selected tab.
+ const terminalSurface = page.getByTestId("terminal-surface").filter({ visible: true });
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts
index 95c6a4d689..2b4aa46e9b 100644
--- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts
+++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts
@@ -301,3 +301,10 @@ export async function refreshAllUsage(page: Page): Promise {
export async function openCompactSidebar(page: Page): Promise {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
}
+
+export async function expectCompactSidebarOpen(page: Page): Promise {
+ await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+}
diff --git a/packages/app/e2e/support/helpers/workspace-layout.ts b/packages/app/e2e/support/helpers/workspace-layout.ts
index 8fbc893f5f..80c2536fbf 100644
--- a/packages/app/e2e/support/helpers/workspace-layout.ts
+++ b/packages/app/e2e/support/helpers/workspace-layout.ts
@@ -1,4 +1,4 @@
-import type { Page } from "@playwright/test";
+import { expect, type Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import {
@@ -61,3 +61,26 @@ export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: stri
JSON.stringify(stored),
);
}
+
+export async function expectAgentAbsentFromWorkspaceLayout(
+ page: Page,
+ input: { serverId: string; workspaceId: string; agentId: string },
+): Promise {
+ await expect
+ .poll(async () => {
+ const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
+ if (!raw) throw new Error("Workspace layout was not persisted");
+ const stored = z
+ .object({ state: WorkspaceLayoutPersistedStateSchema })
+ .parse(JSON.parse(raw));
+ const key = `${input.serverId}:${input.workspaceId}`;
+ const layout = stored.state.layoutByWorkspace[key];
+ const pins = stored.state.pinnedAgentIdsByWorkspace?.[key] ?? [];
+ // The persisted tree is validated above; inspect all panes including hidden ones.
+ return {
+ containsAgent: JSON.stringify(layout).includes(input.agentId),
+ pinned: pins.includes(input.agentId),
+ };
+ })
+ .toEqual({ containsAgent: false, pinned: false });
+}
diff --git a/packages/app/e2e/support/helpers/workspace-setup.ts b/packages/app/e2e/support/helpers/workspace-setup.ts
index dd1bc779bd..eda1d8d023 100644
--- a/packages/app/e2e/support/helpers/workspace-setup.ts
+++ b/packages/app/e2e/support/helpers/workspace-setup.ts
@@ -376,3 +376,7 @@ export function observeSetupPanelTraffic(page: Page) {
},
};
}
+
+export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
+ return detail.commands.map((command) => command.log).join("\n");
+}
diff --git a/packages/app/eas.json b/packages/app/eas.json
index 6ec7f248d3..719a9379e5 100644
--- a/packages/app/eas.json
+++ b/packages/app/eas.json
@@ -15,6 +15,9 @@
}
},
"production": {
+ "ios": {
+ "image": "macos-tahoe-26.6-xcode-27.1"
+ },
"env": {
"APP_VARIANT": "production"
},
diff --git a/packages/app/index.ts b/packages/app/index.ts
index 82d47bfe8c..d103c5cb79 100644
--- a/packages/app/index.ts
+++ b/packages/app/index.ts
@@ -9,6 +9,8 @@ import "@expo/metro-runtime";
// oxlint-disable-next-line import/no-unassigned-import -- Preserve Expo's entry side effects.
import "expo-router/build/fast-refresh";
import { renderRootComponent } from "expo-router/build/renderRootComponent";
+import { startWatchdog } from "./src/watchdog";
import { RootApp } from "./src/root-app";
+startWatchdog();
renderRootComponent(RootApp);
diff --git a/packages/app/metro.config.cjs b/packages/app/metro.config.cjs
index c1fdbdef77..96a965e1f9 100644
--- a/packages/app/metro.config.cjs
+++ b/packages/app/metro.config.cjs
@@ -5,6 +5,7 @@ const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
+const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const fdroidModuleOverrides = {
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
@@ -16,6 +17,7 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
.toLowerCase();
const config = getDefaultConfig(projectRoot);
+config.cacheVersion = isWatchdogBuild ? "watchdog" : "standard";
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
@@ -75,6 +77,14 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
}
config.resolver.resolveRequest = (context, moduleName, platform) => {
+ if (
+ isWatchdogBuild &&
+ platform === "android" &&
+ /[/\\]Renderer[/\\]shims[/\\]/.test(context.originModulePath) &&
+ /implementations\/(ReactFabric|ReactNativeRenderer)-prod$/.test(moduleName)
+ ) {
+ return defaultResolveRequest(context, moduleName.replace(/-prod$/, "-profiling"), platform);
+ }
if (isFdroidBuild && platform === "android" && fdroidModuleOverrides[moduleName]) {
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
}
diff --git a/packages/app/modules/paseo-native-trace/android/build.gradle b/packages/app/modules/paseo-native-trace/android/build.gradle
index bfd0eec72a..c0340730ff 100644
--- a/packages/app/modules/paseo-native-trace/android/build.gradle
+++ b/packages/app/modules/paseo-native-trace/android/build.gradle
@@ -13,3 +13,7 @@ useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.trace"
}
+
+dependencies {
+ testImplementation "junit:junit:4.13.2"
+}
diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DiagnosticRecorder.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DiagnosticRecorder.kt
new file mode 100644
index 0000000000..512795838c
--- /dev/null
+++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DiagnosticRecorder.kt
@@ -0,0 +1,77 @@
+package sh.paseo.trace
+
+import android.content.Context
+import android.os.SystemClock
+import android.util.Log
+import org.json.JSONObject
+import java.util.UUID
+import java.util.concurrent.ArrayBlockingQueue
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicLong
+
+/** All serialization and storage belongs to the worker, never the JS/UI threads. */
+internal class DiagnosticRecorder(private val context: Context, private val metadata: Map) {
+ private data class Event(val name: String, val data: Map, val monoMs: Long = SystemClock.elapsedRealtime(), val wallMs: Long = System.currentTimeMillis())
+ private val queue = ArrayBlockingQueue(2048)
+ private val dropped = AtomicLong()
+ private val worker = Executors.newSingleThreadScheduledExecutor()
+ private val session = UUID.randomUUID().toString()
+ @Volatile private var failed = false
+ private val watchdog = HeartbeatWatchdog(SystemClock.elapsedRealtime(), ::record)
+
+ private val log = RotatingLog(DownloadsLogStorage(context, session), { part ->
+ serialize(Event("file.start", metadata + mapOf("schema" to "1", "part" to "$part")))
+ }, { error ->
+ failed = true
+ queue.clear()
+ Log.e("PaseoDiagnostics", "Diagnostic recording stopped: storage unavailable", error)
+ })
+
+ init {
+ record("session.start", metadata + mapOf("session" to session, "schema" to "1", "heartbeatMs" to "${HeartbeatTiming.intervalMs}", "stallThresholdMs" to "${HeartbeatTiming.overdueMs}"))
+ worker.scheduleWithFixedDelay({ drain() }, 0, HeartbeatTiming.intervalMs, TimeUnit.MILLISECONDS)
+ }
+
+ fun record(name: String, data: Map) {
+ if (failed) return
+ // Limit native memory even if an instrumentation caller accidentally supplies large fields.
+ val bounded = data.entries.take(24).associate { it.key.take(80) to it.value.take(256) }
+ if (!queue.offer(Event(name.take(120), bounded))) dropped.incrementAndGet()
+ }
+
+ fun heartbeat() = watchdog.heartbeat(SystemClock.elapsedRealtime())
+ fun active(value: Boolean) = watchdog.active(value, SystemClock.elapsedRealtime())
+
+ private fun drain() {
+ if (failed) return
+ watchdog.check(SystemClock.elapsedRealtime())
+ val lost = dropped.getAndSet(0)
+ if (lost > 0) write(Event("recorder.dropped", mapOf("count" to "$lost")))
+ var remaining = 2048
+ while (remaining-- > 0 && !failed) {
+ val event = queue.poll() ?: break
+ write(event)
+ }
+ log.finishBatch()
+ }
+
+ private fun write(event: Event) = log.write(serialize(event))
+
+ private fun serialize(event: Event): ByteArray = (JSONObject().apply {
+ put("event", event.name)
+ put("session", session)
+ put("wallMs", event.wallMs)
+ put("monoMs", event.monoMs)
+ put("data", JSONObject(event.data))
+ }.toString() + "\n").toByteArray(Charsets.UTF_8)
+
+ fun close() {
+ worker.execute {
+ record("session.end", emptyMap())
+ drain()
+ log.finishBatch()
+ }
+ worker.shutdown()
+ }
+}
diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DownloadsLogStorage.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DownloadsLogStorage.kt
new file mode 100644
index 0000000000..63608a354c
--- /dev/null
+++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DownloadsLogStorage.kt
@@ -0,0 +1,36 @@
+package sh.paseo.trace
+
+import android.content.ContentUris
+import android.content.ContentValues
+import android.content.Context
+import android.net.Uri
+import android.provider.MediaStore
+import java.io.OutputStream
+
+internal class DownloadsLogStorage(private val context: Context, private val session: String) : LogStorage {
+ private val resolver = context.contentResolver
+ private val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
+ private val directory = "Download/Paseo diagnostics/"
+
+ override fun create(part: Int): String {
+ val values = ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, "paseo-diagnostics-${System.currentTimeMillis()}-${session.take(8)}-$part.jsonl")
+ put(MediaStore.MediaColumns.MIME_TYPE, "application/x-ndjson")
+ put(MediaStore.MediaColumns.RELATIVE_PATH, directory)
+ }
+ return checkNotNull(resolver.insert(collection, values)).toString()
+ }
+
+ override fun append(id: String): OutputStream =
+ checkNotNull(resolver.openOutputStream(Uri.parse(id), "wa")).buffered(64 * 1024)
+
+ override fun newest(): List = buildList {
+ resolver.query(collection, arrayOf(MediaStore.MediaColumns._ID),
+ "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?",
+ arrayOf(directory, context.packageName), "${MediaStore.MediaColumns._ID} DESC")?.use { cursor ->
+ while (cursor.moveToNext()) add(ContentUris.withAppendedId(collection, cursor.getLong(0)).toString())
+ }
+ }
+
+ override fun delete(id: String) { resolver.delete(Uri.parse(id), null, null) }
+}
diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/HeartbeatWatchdog.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/HeartbeatWatchdog.kt
new file mode 100644
index 0000000000..dcf5d1aba1
--- /dev/null
+++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/HeartbeatWatchdog.kt
@@ -0,0 +1,38 @@
+package sh.paseo.trace
+
+internal object HeartbeatTiming {
+ const val intervalMs = 250L
+ const val overdueMs = 500L
+ const val gapMs = intervalMs + overdueMs
+}
+
+/** Monotonic time is supplied by the owner, so suspension and recovery are deterministic. */
+internal class HeartbeatWatchdog(startMs: Long, private val emit: (String, Map) -> Unit) {
+ private var foreground = true
+ private var lastHeartbeat = startMs
+ private var overdue = false
+
+ @Synchronized fun heartbeat(now: Long) {
+ if (foreground && now - lastHeartbeat > HeartbeatTiming.gapMs) {
+ emit("js.stall.recovered", mapOf("heartbeatGapMs" to "${now - lastHeartbeat}", "overdueMs" to "${now - lastHeartbeat - HeartbeatTiming.intervalMs}"))
+ }
+ lastHeartbeat = now
+ overdue = false
+ }
+
+ @Synchronized fun active(value: Boolean, now: Long) {
+ if (foreground == value) return
+ emit("app.lifecycle", mapOf("state" to if (value) "foreground" else "background", "stallOpen" to "$overdue"))
+ foreground = value
+ lastHeartbeat = now
+ overdue = false
+ }
+
+ @Synchronized fun check(now: Long) {
+ val gap = now - lastHeartbeat
+ if (foreground && gap > HeartbeatTiming.gapMs && !overdue) {
+ emit("js.stall.started", mapOf("heartbeatGapMs" to "$gap", "lastHeartbeatMonoMs" to "$lastHeartbeat"))
+ overdue = true
+ }
+ }
+}
diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt
index 4219993e36..5a048b30e1 100644
--- a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt
+++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt
@@ -5,9 +5,23 @@ import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class PaseoNativeTraceModule : Module() {
+ private var recorder: DiagnosticRecorder? = null
+
override fun definition() = ModuleDefinition {
Name("PaseoNativeTrace")
+ Function("startRecorder") { metadata: Map ->
+ if (recorder == null) {
+ appContext.reactContext?.applicationContext?.let { recorder = DiagnosticRecorder(it, metadata) }
+ }
+ HeartbeatTiming.intervalMs
+ }
+ Function("record") { name: String, data: Map -> recorder?.record(name, data) }
+ Function("heartbeat") { recorder?.heartbeat() }
+ OnActivityEntersForeground { recorder?.active(true) }
+ OnActivityEntersBackground { recorder?.active(false) }
+ OnDestroy { recorder?.close(); recorder = null }
+
Function("beginSection") { name: String ->
Trace.beginSection(name.take(127))
}
diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/RotatingLog.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/RotatingLog.kt
new file mode 100644
index 0000000000..194b30aa8a
--- /dev/null
+++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/RotatingLog.kt
@@ -0,0 +1,61 @@
+package sh.paseo.trace
+
+import java.io.OutputStream
+
+/** Storage supplies installation-owned files, newest first. */
+internal interface LogStorage {
+ fun create(part: Int): String
+ fun append(id: String): OutputStream
+ fun newest(): List
+ fun delete(id: String)
+}
+
+/** Owns file identity, bounded retention and the terminal storage-failure state. */
+internal class RotatingLog(
+ private val storage: LogStorage,
+ private val header: (Int) -> ByteArray,
+ private val onFailure: (Exception) -> Unit,
+ private val maxBytes: Int = 5 * 1024 * 1024,
+) {
+ private var current: String? = null
+ private var output: OutputStream? = null
+ private var size = 0
+ private var part = 0
+ var failed = false
+ private set
+
+ fun write(bytes: ByteArray) = attempt {
+ if (current == null || size + bytes.size > maxBytes) {
+ finishBatch()
+ if (failed) return@attempt
+ val nextPart = part++
+ current = storage.create(nextPart)
+ output = storage.append(checkNotNull(current))
+ val first = header(nextPart)
+ output!!.write(first)
+ size = first.size
+ storage.newest().drop(8).forEach(storage::delete)
+ }
+ if (output == null) output = storage.append(checkNotNull(current))
+ output!!.write(bytes)
+ size += bytes.size
+ }
+
+ // Closing publishes size and modification time in Android Files.
+ fun finishBatch() = attempt {
+ output?.close()
+ output = null
+ }
+
+ private fun attempt(action: () -> Unit) {
+ if (failed) return
+ try {
+ action()
+ } catch (error: Exception) {
+ failed = true
+ runCatching { output?.close() }
+ output = null
+ onFailure(error)
+ }
+ }
+}
diff --git a/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/HeartbeatWatchdogTest.kt b/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/HeartbeatWatchdogTest.kt
new file mode 100644
index 0000000000..870c031997
--- /dev/null
+++ b/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/HeartbeatWatchdogTest.kt
@@ -0,0 +1,40 @@
+package sh.paseo.trace
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class HeartbeatWatchdogTest {
+ @Test fun recordsOneStartAndRecoveryWithMeasuredGap() {
+ val events = mutableListOf>>()
+ val watchdog = HeartbeatWatchdog(0) { name, fields -> events.add(name to fields) }
+ watchdog.heartbeat(250)
+ watchdog.check(1000)
+ assertEquals(emptyList>>(), events)
+ watchdog.check(1001)
+ watchdog.check(2000)
+ watchdog.heartbeat(2250)
+ assertEquals(listOf(
+ "js.stall.started" to mapOf("heartbeatGapMs" to "751", "lastHeartbeatMonoMs" to "250"),
+ "js.stall.recovered" to mapOf("heartbeatGapMs" to "2000", "overdueMs" to "1750")
+ ), events)
+ }
+
+ @Test fun backgroundAndResumeDoNotLookLikeStalls() {
+ val events = mutableListOf()
+ val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) }
+ watchdog.active(false, 100)
+ watchdog.check(300000)
+ watchdog.heartbeat(300100)
+ watchdog.active(true, 400000)
+ watchdog.heartbeat(400250)
+ watchdog.check(400500)
+ assertEquals(listOf("app.lifecycle", "app.lifecycle"), events)
+ }
+
+ @Test fun recoveryIsReportedEvenIfNativeSamplingMissedTheGap() {
+ val events = mutableListOf()
+ val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) }
+ watchdog.heartbeat(900)
+ assertEquals(listOf("js.stall.recovered"), events)
+ }
+}
diff --git a/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/RotatingLogTest.kt b/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/RotatingLogTest.kt
new file mode 100644
index 0000000000..ae021278a2
--- /dev/null
+++ b/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/RotatingLogTest.kt
@@ -0,0 +1,59 @@
+package sh.paseo.trace
+
+import java.io.ByteArrayOutputStream
+import java.io.IOException
+import java.io.OutputStream
+import org.junit.Assert.*
+import org.junit.Test
+
+class RotatingLogTest {
+ private class MemoryStorage : LogStorage {
+ val files = linkedMapOf()
+ var fail = false
+ var opens = 0
+ override fun create(part: Int): String {
+ val id = "part-$part"
+ files[id] = ByteArrayOutputStream()
+ return id
+ }
+ override fun append(id: String): OutputStream {
+ opens++
+ if (fail) throw IOException("disk full")
+ return files.getValue(id)
+ }
+ override fun newest() = files.keys.reversed()
+ override fun delete(id: String) { files.remove(id) }
+ }
+
+ @Test fun rotatesAtByteLimitAndKeepsEightIncludingCurrent() {
+ val storage = MemoryStorage()
+ val log = RotatingLog(storage, { "header:$it\n".toByteArray() }, { throw it }, 20)
+ repeat(10) { log.write("123456789012".toByteArray()); log.finishBatch() }
+ assertEquals((2..9).map { "part-$it" }, storage.files.keys.toList())
+ for (part in 2..9) assertEquals("header:$part\n123456789012", storage.files.getValue("part-$part").toString("UTF-8"))
+ assertFalse(log.failed)
+ }
+
+ @Test fun reopeningBatchAppendsWithoutRepeatingHeaderAndExactLimitFits() {
+ val storage = MemoryStorage()
+ val log = RotatingLog(storage, { "H".toByteArray() }, { throw it }, 5)
+ log.write("12".toByteArray())
+ log.finishBatch()
+ log.write("34".toByteArray())
+ assertEquals(listOf("part-0"), storage.files.keys.toList())
+ assertEquals("H1234", storage.files.getValue("part-0").toString("UTF-8"))
+ log.write("5".toByteArray())
+ assertEquals("H5", storage.files.getValue("part-1").toString("UTF-8"))
+ }
+
+ @Test fun storageFailureStopsRecordingWithoutRetryStorm() {
+ val storage = MemoryStorage()
+ var failures = 0
+ val log = RotatingLog(storage, { byteArrayOf() }, { failures++ })
+ storage.fail = true
+ repeat(100) { log.write(byteArrayOf(1)); log.finishBatch() }
+ assertTrue(log.failed)
+ assertEquals(1, failures)
+ assertEquals(1, storage.opens)
+ }
+}
diff --git a/packages/app/package.json b/packages/app/package.json
index 7f1ff46029..b0b806c7ab 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,19 +1,19 @@
{
"name": "@getpaseo/app",
- "version": "0.12.0",
+ "version": "0.13.0",
"private": true,
"main": "index.ts",
"scripts": {
"start": "npm run start:expo",
"start:expo": "cross-env APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
- "eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
- "android": "npm run android:development",
- "android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
+ "eas-build-post-install": "npm --prefix ../.. run build:app-deps",
+ "android": "npm run dev:android",
+ "android:development": "npm run dev:android",
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
"android:release": "npm run android:production",
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
- "ios": "npm --prefix ../.. run build:client && expo run:ios",
+ "ios": "npm run dev:ios",
"ios:release": "npm --prefix ../.. run build:client && expo run:ios --configuration Release",
"web": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run web:expo\"",
"web:expo": "expo start --web",
@@ -35,8 +35,12 @@
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
- "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
- "build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
+ "build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs",
+ "dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
+ "dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
+ "build:apk": "npm --prefix ../.. run build:apk",
+ "build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
+ "test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
diff --git a/packages/app/scripts/build-terminal-webview-html.mjs b/packages/app/scripts/build-terminal-webview-html.mjs
deleted file mode 100644
index d3b02336b2..0000000000
--- a/packages/app/scripts/build-terminal-webview-html.mjs
+++ /dev/null
@@ -1,89 +0,0 @@
-import esbuild from "esbuild";
-import fs from "node:fs/promises";
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const appRoot = path.resolve(__dirname, "..");
-const repoRoot = path.resolve(appRoot, "../..");
-const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts");
-const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts");
-
-async function resolveTsPath(basePath) {
- const candidates = [
- basePath,
- `${basePath}.ts`,
- `${basePath}.tsx`,
- `${basePath}.js`,
- `${basePath}.jsx`,
- path.join(basePath, "index.ts"),
- path.join(basePath, "index.tsx"),
- path.join(basePath, "index.js"),
- path.join(basePath, "index.jsx"),
- ];
- for (const candidate of candidates) {
- try {
- const stat = await fs.stat(candidate);
- if (stat.isFile()) return candidate;
- } catch {
- // try next candidate
- }
- }
- return basePath;
-}
-
-const aliasPlugin = {
- name: "paseo-alias",
- setup(build) {
- build.onResolve({ filter: /^@\// }, async (args) => ({
- path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
- }));
- build.onResolve({ filter: /^@server\// }, async (args) => ({
- path: await resolveTsPath(
- path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)),
- ),
- }));
- },
-};
-
-const result = await esbuild.build({
- entryPoints: [entry],
- bundle: true,
- write: false,
- format: "iife",
- platform: "browser",
- target: ["ios15", "chrome100"],
- loader: {
- ".css": "text",
- },
- plugins: [aliasPlugin],
- logLevel: "info",
-});
-
-const js = result.outputFiles[0]?.text;
-if (!js) {
- throw new Error("terminal webview bundle produced no JavaScript");
-}
-
-const html = `
-
-
-
-
-
-
-
-
-`;
-
-const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
-// Do not edit by hand.
-
-export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
-`;
-
-await fs.writeFile(output, contents);
-console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
diff --git a/packages/app/src/agent-presentation/index.ts b/packages/app/src/agent-presentation/index.ts
new file mode 100644
index 0000000000..f3c3f139f7
--- /dev/null
+++ b/packages/app/src/agent-presentation/index.ts
@@ -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,
+ };
+}
diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts
index dcb52fd24b..ef3a30e5c5 100644
--- a/packages/app/src/agent-stream/presentation.test.ts
+++ b/packages/app/src/agent-stream/presentation.test.ts
@@ -1,3 +1,4 @@
+import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate";
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
import type { InstalledPlugin } from "@/plugins/types";
@@ -61,6 +62,8 @@ function installProbe(
serverId: "host-1",
clientBundle,
lifetime: new AbortController(),
+ locale: "en",
+ i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 559726d7c8..99022863f5 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -2,12 +2,12 @@ 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";
import { LucideProvider } from "lucide-react-native";
import * as Linking from "expo-linking";
-import * as Notifications from "expo-notifications";
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
import {
createContext,
@@ -16,7 +16,6 @@ import {
useContext,
useEffect,
useMemo,
- useRef,
useState,
useSyncExternalStore,
} from "react";
@@ -51,11 +50,7 @@ import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-short
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
-import {
- getIsElectronRuntime,
- HEADER_INNER_HEIGHT,
- useIsCompactFormFactor,
-} from "@/constants/layout";
+import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
@@ -97,7 +92,6 @@ import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
-import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
import {
@@ -132,14 +126,9 @@ import {
parseHostWorkspaceRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
-import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
-import { navigateToAgent } from "@/utils/navigate-to-agent";
import { PluginCatalogSync } from "@/plugins";
-import {
- ensureOsNotificationPermission,
- WEB_NOTIFICATION_CLICK_EVENT,
- type WebNotificationClickDetail,
-} from "@/utils/os-notifications";
+import { NotificationOpenRouter } from "@/notification-open/react";
+import { ensureOsNotificationPermission } from "@/utils/os-notifications";
polyfillCrypto();
@@ -159,114 +148,6 @@ const HostRuntimeBootstrapContext = createContext({
startupBlocker: { kind: "none" },
});
-function PushNotificationRouter() {
- const router = useRouter();
- const lastHandledIdRef = useRef(null);
- const openNotification = useStableEvent((data: Record | undefined) => {
- const target = resolveNotificationTarget(data);
- const serverId = target.serverId;
- const workspaceId = target.workspaceId;
- const agentId = target.agentId;
- if (serverId && workspaceId && agentId) {
- navigateToAgent({ serverId, workspaceId, agentId, pin: true });
- return;
- }
-
- router.navigate(buildNotificationRoute(data));
- });
-
- useEffect(() => {
- if (isWeb) {
- let removeDesktopNotificationListener: (() => void) | null = null;
- let cancelled = false;
-
- if (getIsElectronRuntime()) {
- void ensureOsNotificationPermission();
-
- const unlistenResult = getDesktopHost()?.events?.on?.(
- "notification-click",
- (payload: unknown) => {
- const data =
- typeof payload === "object" &&
- payload !== null &&
- "data" in payload &&
- typeof (payload as { data?: unknown }).data === "object" &&
- (payload as { data?: unknown }).data !== null
- ? (payload as { data: Record }).data
- : undefined;
- openNotification(data);
- },
- );
-
- void Promise.resolve(unlistenResult).then((unlisten) => {
- if (typeof unlisten !== "function") {
- return;
- }
- if (cancelled) {
- unlisten();
- return;
- }
- removeDesktopNotificationListener = unlisten;
- return;
- });
- }
-
- const openFromWebClick = (event: Event) => {
- const customEvent = event as CustomEvent;
- event.preventDefault();
- openNotification(customEvent.detail?.data);
- };
-
- window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
-
- return () => {
- cancelled = true;
- removeDesktopNotificationListener?.();
- window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
- };
- }
-
- Notifications.setNotificationHandler({
- handleNotification: async () => ({
- // When the app is open, don't show OS banners.
- shouldShowAlert: false,
- shouldShowBanner: false,
- shouldShowList: false,
- shouldPlaySound: false,
- shouldSetBadge: false,
- }),
- });
-
- const openFromResponse = (response: Notifications.NotificationResponse) => {
- const identifier = response.notification.request.identifier;
- if (lastHandledIdRef.current === identifier) {
- return;
- }
- lastHandledIdRef.current = identifier;
-
- const data = response.notification.request.content.data as
- | Record
- | undefined;
- openNotification(data);
- };
-
- const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
-
- void Notifications.getLastNotificationResponseAsync().then((response) => {
- if (response) {
- openFromResponse(response);
- }
- return;
- });
-
- return () => {
- subscription.remove();
- };
- }, [openNotification]);
-
- return null;
-}
-
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const client = useHostRuntimeClient(daemon.serverId);
@@ -627,7 +508,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
return (
- {surface}
+ {surface}
);
}
@@ -945,6 +826,9 @@ function WorkspaceRouteNavigationBridge() {
}
function AppShell() {
+ useEffect(() => {
+ if (getDesktopHost()) void ensureOsNotificationPermission();
+ }, []);
return (
@@ -959,10 +843,15 @@ function AppShell() {
);
}
+function NotificationOpenBridge() {
+ const { startupBlocker } = useHostRuntimeBootstrapState();
+ return ;
+}
+
function RuntimeProviders({ children }: { children: ReactNode }) {
return (
-
+
{children}
diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx
index 7f66a07b25..008999a540 100644
--- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx
+++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx
@@ -4,6 +4,8 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
+import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
+import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
@@ -83,7 +85,16 @@ function HostAgentReadyRouteContent() {
lookupQuery.isSuccess,
shouldLookupAgent,
]);
+ const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
+ const hasHydratedLayout = useWorkspaceLayoutStoreHydrated();
+ useEffect(() => {
+ if (!serverId) return;
+ // A compact app can open this route before its sidebar has demanded the directory.
+ return getHostRuntimeStore().acquireDirectoryDemand(serverId);
+ }, [serverId]);
const resolution = resolveAgentRoute({
+ hasHydratedWorkspaces,
+ hasHydratedLayout,
serverId,
agentId,
cachedWorkspaceId: agentWorkspaceId,
@@ -106,7 +117,7 @@ function HostAgentReadyRouteContent() {
handledNavigationRef.current = navigationKey;
if (resolution.kind === "resolved") {
- navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
+ navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId, pin: true });
return;
}
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx
index 80ba6df4d4..8f80f185c7 100644
--- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx
+++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx
@@ -1,7 +1,7 @@
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import { useNavigation } from "expo-router/react-navigation";
import { StyleSheet, View } from "react-native";
-import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
+import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { RetainedPanel } from "@/components/retained-panel";
import {
@@ -11,7 +11,7 @@ import {
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
-import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
+import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
areRetainedWorkspaceSelectionListsEqual,
areWorkspaceSelectionsEqual,
@@ -33,7 +33,7 @@ import {
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
} from "@/utils/host-route-browser";
-import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
+import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab";
import { isNative, isWeb } from "@/constants/platform";
import { RenderProfile } from "@/utils/render-profiler";
@@ -64,6 +64,8 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
return { kind: "draft", draftId: openIntent.draftId };
}
+// COMPAT(workspace-open-url): added in v0.12.0, remove after 2027-01-22.
+// Bookmarks and older clients still send ?open=; internal navigation uses pending model state.
function stripOpenSearchParamFromBrowserUrl() {
if (!isWeb || typeof window === "undefined") {
return;
@@ -81,7 +83,9 @@ function clearConsumedOpenIntent(input: {
}) {
input.navigation.setParams({ open: undefined });
if (isWeb) {
- stripOpenSearchParamFromBrowserUrl();
+ // Expo commits the incoming browser URL after setParams. Strip the consumed legacy input
+ // after that commit so it cannot be restored into the address bar and replayed on reload.
+ window.setTimeout(stripOpenSearchParamFromBrowserUrl, 0);
}
}
@@ -95,15 +99,9 @@ export default function HostWorkspaceIndexRoute() {
function HostWorkspaceRouteContent() {
const navigation = useNavigation();
- const rootNavigationState = useRootNavigationState();
- const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
- const consumedIntentRef = useRef(null);
- const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
- }>();
- const globalParams = useGlobalSearchParams<{
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
@@ -111,14 +109,7 @@ function HostWorkspaceRouteContent() {
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
- const openValue = getParamValue(globalParams.open);
- const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
- const workspaceExists = useWorkspaceExists(serverId, workspaceId);
- const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
- const isAgentOpenIntent = openIntent?.kind === "agent";
- const isOpenIntentWaitingForWorkspace = Boolean(
- isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
- );
+ const openValue = getParamValue(params.open);
useEffect(() => {
if (!serverId || !workspaceId) {
return;
@@ -126,74 +117,31 @@ function HostWorkspaceRouteContent() {
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
}, [serverId, workspaceId]);
- useEffect(() => {
- if (!openValue) {
- return;
- }
- if (!rootNavigationState?.key) {
- return;
- }
- if (!hasHydratedWorkspaceLayoutStore) {
- return;
- }
- if (isOpenIntentWaitingForWorkspace) {
- return;
- }
-
- const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
- if (consumedIntentRef.current === consumptionKey) {
- clearConsumedOpenIntent({
- navigation: navigation as unknown as {
- setParams: (params: { open?: string | undefined }) => void;
- },
- });
- setIntentConsumed(true);
- return;
- }
- consumedIntentRef.current = consumptionKey;
-
+ useLayoutEffect(() => {
+ if (!openValue || !serverId || !workspaceId) return;
+ const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
- prepareWorkspaceTab({
- serverId,
- workspaceId,
- target: getOpenIntentTarget(openIntent),
- pin: openIntent.kind === "agent",
- });
+ prepareWorkspaceTab(
+ {
+ serverId,
+ workspaceId,
+ target: getOpenIntentTarget(openIntent),
+ pin: openIntent.kind === "agent",
+ },
+ { openTab: useWorkspaceLayoutStore.getState().deferOpenTab },
+ );
}
-
- // Expo Router's replace ignores query-param-only changes (findDivergentState
- // skips search params). Strip ?open from the browser URL directly so the
- // address bar reflects the clean workspace route.
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
+ }, [navigation, openValue, serverId, workspaceId]);
- setIntentConsumed(true);
- }, [
- hasHydratedWorkspaceLayoutStore,
- isOpenIntentWaitingForWorkspace,
- navigation,
- openIntent,
- openValue,
- rootNavigationState?.key,
- serverId,
- workspaceId,
- ]);
-
- if (
- openValue &&
- !isOpenIntentWaitingForWorkspace &&
- (!intentConsumed || !hasHydratedWorkspaceLayoutStore)
- ) {
- return null;
- }
-
- return ;
+ return ;
}
-function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
+function WorkspaceDeck() {
const activeSelection = useActiveWorkspaceSelection();
const [retainedSelections, setRetainedSelections] = useState(() =>
activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [],
@@ -264,7 +212,6 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
key={getWorkspaceSelectionKey(selection)}
selection={selection}
active={active}
- recoveryRequested={recoveryRequested}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -277,12 +224,10 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
function WorkspaceDeckEntry({
selection,
active,
- recoveryRequested,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
active: boolean;
- recoveryRequested: boolean;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
@@ -312,7 +257,6 @@ function WorkspaceDeckEntry({
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={active}
- recoveryRequested={active && recoveryRequested}
/>
);
diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts
index 9b66e99c18..6d80abb1a6 100644
--- a/packages/app/src/assistant-file-links/index.ts
+++ b/packages/app/src/assistant-file-links/index.ts
@@ -6,6 +6,7 @@ export {
export {
AssistantFileLinkResolverProvider,
useAssistantFileLinkEnvironment,
+ useAssistantFileLinkEnvironmentBridge,
type AssistantFileLinkResolverProviderProps,
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
diff --git a/packages/app/src/assistant-file-links/provider.tsx b/packages/app/src/assistant-file-links/provider.tsx
index a8773a2398..935683bb0e 100644
--- a/packages/app/src/assistant-file-links/provider.tsx
+++ b/packages/app/src/assistant-file-links/provider.tsx
@@ -39,6 +39,18 @@ export interface AssistantFileLinkResolverContextValue {
const AssistantFileLinkResolverContext =
createContext(null);
+export function useAssistantFileLinkEnvironmentBridge() {
+ const value = useContext(AssistantFileLinkResolverContext);
+ return useCallback(
+ (children: ReactNode) => (
+
+ {children}
+
+ ),
+ [value],
+ );
+}
+
export function AssistantFileLinkResolverProvider({
client,
serverId,
diff --git a/packages/app/src/assistant-file-links/use-file-link.test.tsx b/packages/app/src/assistant-file-links/use-file-link.test.tsx
index 386cb429f1..18d2727e82 100644
--- a/packages/app/src/assistant-file-links/use-file-link.test.tsx
+++ b/packages/app/src/assistant-file-links/use-file-link.test.tsx
@@ -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();
diff --git a/packages/app/src/attachments/upload-limit.ts b/packages/app/src/attachments/upload-limit.ts
new file mode 100644
index 0000000000..8a81043f4b
--- /dev/null
+++ b/packages/app/src/attachments/upload-limit.ts
@@ -0,0 +1,2 @@
+/** Largest file the app sends through `client.uploadFile`. */
+export const FILE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx
index 58f1a4ce54..7a44f8ccd9 100644
--- a/packages/app/src/command-center/command-center.tsx
+++ b/packages/app/src/command-center/command-center.tsx
@@ -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(),
+ );
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();
+ const preserve = (
+ 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;
offsets: readonly number[];
inputRef: React.RefObject;
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(null);
const previousOpenRef = useRef(open);
- const [query, setQueryState] = useState("");
- const [activeId, setActiveId] = useState(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
}
>
-
+ {(state) => (
+
+ )}
);
});
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function WorkspaceResultIcon({
+ result,
+ backdrop,
+}: {
+ result: CommandCenterWorkspaceResult;
+ backdrop: SurfaceBackdrop;
+}) {
+ const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId);
+ return (
+
+
+
+
+
+ );
+}
+
/** 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 (
@@ -525,34 +654,7 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
);
}
if (result.kind === "agent") {
- const agent = result.agent;
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
+ return ;
}
if (result.kind === "workspace") {
const key = result.id.slice("workspace:".length);
@@ -560,13 +662,8 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
-
-
+
+
>(null);
const bottomSheetListRef = useRef(null);
const bottomSheetInputRef = useRef(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) => {
+ interaction.scrolled(event.nativeEvent.contentOffset.y);
+ },
+ [interaction],
);
- const handleListScroll = useCallback((event: NativeSyntheticEvent) => {
- 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(
diff --git a/packages/app/src/command-center/interaction.test.ts b/packages/app/src/command-center/interaction.test.ts
new file mode 100644
index 0000000000..5b5d8bdef9
--- /dev/null
+++ b/packages/app/src/command-center/interaction.test.ts
@@ -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((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([]);
+ });
+});
diff --git a/packages/app/src/command-center/interaction.ts b/packages/app/src/command-center/interaction.ts
new file mode 100644
index 0000000000..8ac0acce15
--- /dev/null
+++ b/packages/app/src/command-center/interaction.ts
@@ -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;
diff --git a/packages/app/src/command-center/results.test.ts b/packages/app/src/command-center/results.test.ts
index 688352f00f..fe7bec5897 100644
--- a/packages/app/src/command-center/results.test.ts
+++ b/packages/app/src/command-center/results.test.ts
@@ -110,6 +110,8 @@ function workspace(
): CommandCenterWorkspaceResult {
return {
kind: "workspace",
+ serverId: "server",
+ workspaceId: "workspace",
id,
title,
subtitle,
diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts
index 84fc91d3f0..52034ca893 100644
--- a/packages/app/src/command-center/results.ts
+++ b/packages/app/src/command-center/results.ts
@@ -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;
diff --git a/packages/app/src/command-center/workspace-file-search.ts b/packages/app/src/command-center/workspace-file-search.ts
index 1008604bec..8864da5ea8 100644
--- a/packages/app/src/command-center/workspace-file-search.ts
+++ b/packages/app/src/command-center/workspace-file-search.ts
@@ -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],
diff --git a/packages/app/src/command-center/workspace-search.test.ts b/packages/app/src/command-center/workspace-search.test.ts
index 93bb5fc6bf..b43e9edfbd 100644
--- a/packages/app/src/command-center/workspace-search.test.ts
+++ b/packages/app/src/command-center/workspace-search.test.ts
@@ -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 ?? "",
diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx
index 06f7872995..a1cb5cf779 100644
--- a/packages/app/src/components/add-project-flow.tsx
+++ b/packages/app/src/components/add-project-flow.tsx
@@ -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()));
},
});
diff --git a/packages/app/src/components/connection-failure-copy.test.ts b/packages/app/src/components/connection-failure-copy.test.ts
index 21cfcd40e5..de78ba43eb 100644
--- a/packages/app/src/components/connection-failure-copy.test.ts
+++ b/packages/app/src/components/connection-failure-copy.test.ts
@@ -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],
diff --git a/packages/app/src/components/connection-failure-copy.ts b/packages/app/src/components/connection-failure-copy.ts
index 1491ed01d8..e1ad2f203c 100644
--- a/packages/app/src/components/connection-failure-copy.ts
+++ b/packages/app/src/components/connection-failure-copy.ts
@@ -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;
diff --git a/packages/app/src/components/file-drop/file-drop-backdrop.tsx b/packages/app/src/components/file-drop/file-drop-backdrop.tsx
index bafc9c0227..fa381bf545 100644
--- a/packages/app/src/components/file-drop/file-drop-backdrop.tsx
+++ b/packages/app/src/components/file-drop/file-drop-backdrop.tsx
@@ -34,7 +34,7 @@ export function FileDropBackdrop() {
// non-animated children (applying themed Unistyles styles to an Animated.View crashes
// on theme change — see docs/unistyles.md).
return (
-
+
diff --git a/packages/app/src/components/file-drop/types.ts b/packages/app/src/components/file-drop/types.ts
index 89bbbca7c4..e9b80a00be 100644
--- a/packages/app/src/components/file-drop/types.ts
+++ b/packages/app/src/components/file-drop/types.ts
@@ -13,11 +13,11 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
/**
* What a consumer (e.g. a composer) registers to receive files dropped onto the
- * surrounding FileDropZone. Raster images arrive already persisted via `onFiles`;
- * everything else arrives raw via `onGenericFiles`.
+ * surrounding FileDropZone. Every file arrives raw via `onGenericFiles`; a sink that
+ * also provides `onFiles` gets its raster images persisted as attachments.
*/
export interface FileDropSink {
- onFiles: (images: ImageAttachment[]) => void;
+ onFiles?: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
}
diff --git a/packages/app/src/components/file-drop/use-drop-listeners.ts b/packages/app/src/components/file-drop/use-drop-listeners.ts
index 947c95d91e..2e3dc320b4 100644
--- a/packages/app/src/components/file-drop/use-drop-listeners.ts
+++ b/packages/app/src/components/file-drop/use-drop-listeners.ts
@@ -148,8 +148,9 @@ export function useDropListeners({
sink.onGenericFiles(items);
}
- const imagePaths = payload.paths.filter(isRasterImagePath);
- if (imagePaths.length === 0) {
+ const onFiles = sink.onFiles;
+ const imagePaths = onFiles ? payload.paths.filter(isRasterImagePath) : [];
+ if (!onFiles || imagePaths.length === 0) {
return;
}
@@ -162,7 +163,7 @@ export function useDropListeners({
// composer the user dropped on (matches the web path below). No post-persist busy
// re-check: a mixed drop's own generic upload flips the busy flag, and re-checking
// would discard the image from the same drop.
- sink.onFiles(attachments);
+ onFiles(attachments);
return;
})
.catch((error) => {
@@ -262,16 +263,17 @@ export function useDropListeners({
sink.onGenericFiles(genericItems);
}
- const imageFiles = files.filter(isRasterImageFile);
+ const onFiles = sink.onFiles;
+ const imageFiles = onFiles ? files.filter(isRasterImageFile) : [];
- if (imageFiles.length === 0) return;
+ if (!onFiles || imageFiles.length === 0) return;
try {
const attachments = await Promise.all(imageFiles.map(fileToImageAttachment));
// No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag,
// and re-checking would discard the image from the same drop. The guard at drop start
// already rejects drops that begin while busy.
- sink.onFiles(attachments);
+ onFiles(attachments);
} catch (error) {
console.error("[useDropListeners] Failed to process dropped files:", error);
}
diff --git a/packages/app/src/components/headers/sidebar-close-button.tsx b/packages/app/src/components/headers/sidebar-close-button.tsx
new file mode 100644
index 0000000000..3beaa1f7a9
--- /dev/null
+++ b/packages/app/src/components/headers/sidebar-close-button.tsx
@@ -0,0 +1,59 @@
+import { X } from "lucide-react-native";
+import type { StyleProp, ViewStyle } from "react-native";
+import { withUnistyles } from "react-native-unistyles";
+import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
+import {
+ IconButtonChromeProvider,
+ iconButtonChromeGlyphSize,
+ mutedIconColorMapping,
+} from "@/components/ui/icon-button-chrome";
+import type { ShortcutKey } from "@/utils/format-shortcut";
+
+const ThemedX = withUnistyles(X);
+const NO_SHORTCUT: ShortcutKey[] = [];
+
+interface SidebarCloseButtonProps {
+ label: string;
+ onPress: () => void;
+ testID: string;
+ nativeID?: string;
+ accessibilityState?: { expanded: boolean };
+ style?: StyleProp;
+}
+
+/**
+ * The X that closes a compact sidebar overlay. It is the compact header's icon button, so the
+ * sidebar's close reads like the header it covers; every compact sidebar uses it.
+ */
+export function SidebarCloseButton({
+ label,
+ onPress,
+ testID,
+ nativeID,
+ accessibilityState,
+ style,
+}: SidebarCloseButtonProps) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 6cf9e29eb1..dbc501b5c7 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -5,7 +5,6 @@ import {
GitBranch,
Server,
Settings,
- X,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -21,14 +20,9 @@ import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
-import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
-import {
- IconButtonChromeProvider,
- iconButtonChromeGlyphSize,
- mutedIconColorMapping,
-} from "@/components/ui/icon-button-chrome";
+import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
@@ -68,7 +62,6 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
-import type { ShortcutKey } from "@/utils/format-shortcut";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import {
@@ -81,8 +74,6 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
-const ThemedX = withUnistyles(X);
-
type SidebarTheme = ReturnType["theme"];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
@@ -175,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();
@@ -214,9 +204,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
}, []);
const handleImportSessionMobile = useCallback(() => {
- showMobileAgent();
openImportSession();
- }, [openImportSession, showMobileAgent]);
+ }, [openImportSession]);
const labels = useMemo(
(): SidebarLabels => ({
@@ -555,7 +544,15 @@ function MobileSidebar({
}, [closeSidebar]);
const closeButton = useMemo(
- () => ,
+ () => (
+
+ ),
[labels.closeSidebar, closeSidebar],
);
@@ -621,34 +618,6 @@ function MobileSidebar({
);
}
-const NO_SHORTCUT: ShortcutKey[] = [];
-
-/** The compact header's icon button, so the sidebar's close reads like the header it covers. */
-function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) {
- return (
-
-
-
-
-
- );
-}
-
function DesktopSidebar({
theme,
workspaceGroups,
@@ -750,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],
diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx
index 73c008dd22..0e1d7d28ab 100644
--- a/packages/app/src/components/sidebar-workspace-list.test.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.test.tsx
@@ -1,7 +1,8 @@
/**
* @vitest-environment jsdom
*/
-import { act } from "@testing-library/react";
+import { act, renderHook } from "@testing-library/react";
+import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -83,6 +84,8 @@ function workspace(input: {
scripts?: WorkspaceDescriptor["scripts"];
}): WorkspaceDescriptor {
return {
+ createdAt: null,
+ lastUserMessageAt: null,
id: input.id,
projectId: input.projectId,
projectDisplayName: input.projectDisplayName,
@@ -171,10 +174,6 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
useSidebarOrderStore.setState({
projectOrder: ["project-a", "project-b"],
- workspaceOrderByProject: {
- ["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`],
- ["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`],
- },
});
});
}
@@ -348,7 +347,6 @@ describe("sidebar workspace render isolation", () => {
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
projectOrder: [],
- workspaceOrderByProject: {},
});
});
});
@@ -439,3 +437,190 @@ describe("sidebar workspace render isolation", () => {
});
});
});
+
+describe("sidebar group visibility", () => {
+ const entries = (statuses: WorkspaceDescriptor["status"][]) =>
+ statuses.map((status, index) =>
+ createSidebarWorkspaceEntry({
+ serverId: SERVER_ID,
+ workspace: workspace({
+ id: `row-${index + 1}`,
+ projectId: "project-a",
+ projectDisplayName: "Project A",
+ name: `Row ${index + 1}`,
+ status,
+ }),
+ }),
+ );
+ const policy = {
+ kind: "workspaces" as const,
+ getStatusBucket: (entry: ReturnType) => entry.statusBucket,
+ };
+ const names = (items: readonly ReturnType[]) =>
+ items.map((item) => item.workspaceId);
+
+ it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
+ const items = entries(Array(count).fill("done"));
+ const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
+ expect(result.current.visibleItems).toEqual(items.slice(0, 7));
+ expect(result.current.canToggle).toBe(count > 7);
+ });
+
+ it.each(["needs_input", "failed", "attention", "running"] as const)(
+ "keeps row twelve visible for %s without filling gaps",
+ (status) => {
+ const items = entries([...Array(11).fill("done"), status]);
+ const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
+ expect(names(result.current.visibleItems)).toEqual([
+ "row-1",
+ "row-2",
+ "row-3",
+ "row-4",
+ "row-5",
+ "row-6",
+ "row-7",
+ "row-12",
+ ]);
+ expect(result.current.visibleItems[7]).toBe(items[11]);
+ expect(result.current.canToggle).toBe(true);
+ },
+ );
+
+ it("shows eight active rows once with no redundant toggle", () => {
+ const items = entries([
+ "running",
+ "needs_input",
+ "attention",
+ "failed",
+ "running",
+ "attention",
+ "needs_input",
+ "failed",
+ ]);
+ const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
+ expect(result.current.visibleItems).toEqual(items);
+ expect(result.current.canToggle).toBe(false);
+ });
+
+ it("recomputes current ordered membership and retains reveal through status, order and size changes", () => {
+ const items = entries([
+ ...Array(7).fill("done"),
+ "running",
+ "done",
+ "failed",
+ "done",
+ "attention",
+ ]);
+ const { result, rerender, unmount } = renderHook(
+ ({ rows }) => useLimitedSidebarGroup(rows, policy),
+ { initialProps: { rows: items } },
+ );
+ expect(names(result.current.visibleItems)).toEqual([
+ "row-1",
+ "row-2",
+ "row-3",
+ "row-4",
+ "row-5",
+ "row-6",
+ "row-7",
+ "row-8",
+ "row-10",
+ "row-12",
+ ]);
+ act(() => result.current.toggleExpanded());
+ expect(result.current.visibleItems).toEqual(items);
+ rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) });
+ expect(result.current.canToggle).toBe(false);
+ expect(result.current.expanded).toBe(true);
+ rerender({ rows: items.slice(0, 3) });
+ expect(result.current.canToggle).toBe(false);
+ rerender({ rows: items.toReversed() });
+ expect(result.current.visibleItems).toEqual(items.toReversed());
+ act(() => result.current.toggleExpanded());
+ expect(names(result.current.visibleItems)).toEqual([
+ "row-12",
+ "row-11",
+ "row-10",
+ "row-9",
+ "row-8",
+ "row-7",
+ "row-6",
+ ]);
+ const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" };
+ rerender({ rows: [newest, ...items] });
+ expect(result.current.visibleItems[0]).toBe(newest);
+ expect(result.current.visibleItems).toHaveLength(10);
+ expect(result.current.expanded).toBe(false);
+ act(() => result.current.toggleExpanded());
+ unmount();
+ const remounted = renderHook(() => useLimitedSidebarGroup(items, policy));
+ expect(remounted.result.current.expanded).toBe(false);
+ expect(remounted.result.current.visibleItems).toHaveLength(10);
+ });
+
+ it("follows effective pending creation, recovery and agent activity without changing Created order", () => {
+ const items = entries(Array(12).fill("done"));
+ const descriptor = workspace({
+ id: "row-12",
+ projectId: "project-a",
+ projectDisplayName: "Project A",
+ name: "Row 12",
+ });
+ const pending = {
+ draftId: "draft",
+ serverId: SERVER_ID,
+ workspaceId: descriptor.id,
+ agentId: null,
+ clientMessageId: "message",
+ text: "Create",
+ timestamp: 1234,
+ lifecycle: "active" as const,
+ };
+ const { result, rerender } = renderHook(
+ ({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy),
+ { initialProps: { last: items[11] } },
+ );
+ expect(result.current.visibleItems).toHaveLength(7);
+ const creating = createSidebarWorkspaceEntry({
+ serverId: SERVER_ID,
+ workspace: descriptor,
+ pendingCreateAttempts: { draft: pending },
+ });
+ expect(creating.statusBucket).toBe("running");
+ rerender({ last: creating });
+ expect(names(result.current.visibleItems)).toEqual([...names(items.slice(0, 7)), "row-12"]);
+ const abandoned = createSidebarWorkspaceEntry({
+ serverId: SERVER_ID,
+ workspace: descriptor,
+ pendingCreateAttempts: { draft: { ...pending, lifecycle: "abandoned" } },
+ });
+ rerender({ last: abandoned });
+ expect(result.current.visibleItems).toHaveLength(7);
+ rerender({ last: creating });
+ for (const status of ["running", "attention", "needs_input", "failed", "done"] as const) {
+ const last = createSidebarWorkspaceEntry({
+ serverId: SERVER_ID,
+ workspace: descriptor,
+ workspaceAgentActivity: new Map([
+ [descriptor.id, { agentId: "agent", status, enteredAt: null }],
+ ]),
+ });
+ expect(last.statusBucket).toBe(status);
+ rerender({ last });
+ expect(names(result.current.visibleItems)).toEqual(
+ status === "done" ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-12"],
+ );
+ }
+ });
+
+ it("preserves the twenty-row pinned policy regardless of status", () => {
+ const items = entries(Array(21).fill("running"));
+ const { result } = renderHook(() => useLimitedSidebarGroup(items));
+ expect(result.current.visibleItems).toEqual(items.slice(0, 20));
+ expect(result.current.canToggle).toBe(true);
+ act(() => result.current.toggleExpanded());
+ expect(result.current.visibleItems).toEqual(items);
+ act(() => result.current.toggleExpanded());
+ expect(result.current.visibleItems).toHaveLength(20);
+ });
+});
diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx
index 74d57ca667..c3048e6d9a 100644
--- a/packages/app/src/components/sidebar-workspace-list.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.tsx
@@ -272,7 +272,7 @@ interface WorkspaceRowInnerProps {
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
- drag: () => void;
+ drag?: () => void;
isDragging: boolean;
isArchiving: boolean;
isCreating?: boolean;
@@ -381,8 +381,6 @@ function getProjectWorkspaceRowStyle({
];
}
-function noop() {}
-
const prBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
@@ -1236,7 +1234,7 @@ function WorkspaceRowWithMenu({
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
- drag: () => void;
+ drag?: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean;
@@ -1403,7 +1401,6 @@ function WorkspaceRowItem({
isCreating = false,
selectionEnabled,
activeWorkspaceSelection,
- onWorkspacePress,
drag,
isDragging = false,
dragHandleProps,
@@ -1412,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 (
@@ -1465,7 +1461,7 @@ function areWorkspaceRowItemPropsEqual(
previous.hostBadge === next.hostBadge &&
previous.leadingProjectName === next.leadingProjectName &&
previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri &&
- previous.shortcutNumber === next.shortcutNumber &&
+ (!next.showShortcutBadge || previous.shortcutNumber === next.shortcutNumber) &&
previous.showShortcutBadge === next.showShortcutBadge &&
previous.canCopyBranchName === next.canCopyBranchName &&
previous.canPin === next.canPin &&
@@ -1507,7 +1503,7 @@ function WorkspaceRow({
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
- drag: () => void;
+ drag?: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean;
@@ -1552,16 +1548,17 @@ function ProjectBlock({
selectionEnabled,
showShortcutBadges,
shortcutIndexByWorkspaceKey,
- parentGestureRef,
onToggleCollapsed,
onWorkspacePress,
+ parentGestureRef,
onWorkspaceReorder,
+ useNestable,
+ dragGestureHostActive,
+ manualSort,
onWorktreeCreated,
drag,
isDragging,
dragHandleProps,
- useNestable,
- dragGestureHostActive,
creatingWorkspaceIds,
activeWorkspaceSelection,
hostBadgeByServerId,
@@ -1577,16 +1574,17 @@ function ProjectBlock({
selectionEnabled: boolean;
showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map;
- parentGestureRef?: MutableRefObject;
onToggleCollapsed: (projectViewKey: string) => void;
onWorkspacePress?: () => void;
+ parentGestureRef?: MutableRefObject;
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
+ useNestable: boolean;
+ dragGestureHostActive?: boolean;
+ manualSort: boolean;
onWorktreeCreated?: (workspaceId: string) => void;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
- useNestable: boolean;
- dragGestureHostActive?: boolean;
creatingWorkspaceIds: ReadonlySet;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostBadgeByServerId: ReadonlyMap;
@@ -1594,12 +1592,20 @@ function ProjectBlock({
supportsPinningByServerId: ReadonlyMap;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
+ const visibilityPolicy = useMemo(
+ () => ({
+ kind: "workspaces" as const,
+ getStatusBucket: (workspace: SidebarWorkspacePlacement) =>
+ workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket,
+ }),
+ [workspaceEntriesByKey],
+ );
const {
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
- } = useLimitedSidebarGroup(project.workspaces);
+ } = useLimitedSidebarGroup(project.workspaces, visibilityPolicy);
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
@@ -1638,6 +1644,7 @@ function ProjectBlock({
) => {
return (
0) {
projectChildren = (
<>
-
+ {manualSort ? (
+
+ ) : (
+
+ {visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
+
+ )}
{canToggleWorkspaces ? (
[0];
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
return (
previous.project === next.project &&
- previous.workspaceEntriesByKey === next.workspaceEntriesByKey &&
+ projectWorkspaceEntriesEqual(previous, next) &&
previous.collapsed === next.collapsed &&
previous.displayName === next.displayName &&
previous.iconDataUri === next.iconDataUri &&
previous.selectionEnabled === next.selectionEnabled &&
previous.showShortcutBadges === next.showShortcutBadges &&
- previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
+ projectShortcutBadgesEqual(previous, next) &&
previous.hostBadgeByServerId === next.hostBadgeByServerId &&
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
- previous.parentGestureRef === next.parentGestureRef &&
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 &&
previous.dragHandleProps === next.dragHandleProps &&
- previous.useNestable === next.useNestable &&
- previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId &&
areProjectBlockSelectionsEqual(previous, next)
);
}
+function projectShortcutBadgesEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
+ if (
+ !next.showShortcutBadges ||
+ previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey
+ )
+ return true;
+ return next.project.workspaces.every(
+ ({ workspaceKey }) =>
+ previous.shortcutIndexByWorkspaceKey.get(workspaceKey) ===
+ next.shortcutIndexByWorkspaceKey.get(workspaceKey),
+ );
+}
+
+function projectWorkspaceEntriesEqual(
+ previous: ProjectBlockProps,
+ next: ProjectBlockProps,
+): boolean {
+ if (previous.workspaceEntriesByKey === next.workspaceEntriesByKey) return true;
+ return next.project.workspaces.every(
+ ({ workspaceKey }) =>
+ previous.workspaceEntriesByKey.get(workspaceKey) ===
+ next.workspaceEntriesByKey.get(workspaceKey),
+ );
+}
+
function areProjectBlockSelectionsEqual(
previous: ProjectBlockProps,
next: ProjectBlockProps,
@@ -2135,10 +2177,11 @@ function ProjectModeList({
(state) => state.togglePinnedCollapsed,
);
- const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
- const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
+ 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);
const isWorkspaceRoute = useMemo(
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
@@ -2306,16 +2349,17 @@ function ProjectModeList({
selectionEnabled={selectionEnabled}
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
- parentGestureRef={parentGestureRef}
onToggleCollapsed={onToggleProjectCollapsed}
onWorkspacePress={onWorkspacePress}
+ manualSort={manualSort}
+ parentGestureRef={parentGestureRef}
onWorkspaceReorder={handleWorkspaceReorder}
+ useNestable={platformIsNative}
+ dragGestureHostActive={dragGestureHostActive}
onWorktreeCreated={handleWorktreeCreated}
drag={dragState.drag}
isDragging={dragState.isDragging}
dragHandleProps={dragState.dragHandleProps}
- useNestable={platformIsNative}
- dragGestureHostActive={dragGestureHostActive}
creatingWorkspaceIds={creatingWorkspaceIds}
activeWorkspaceSelection={activeWorkspaceSelection}
hostBadgeByServerId={hostBadgeByServerId}
@@ -2329,6 +2373,9 @@ function ProjectModeList({
collapsedProjectKeys,
activeWorkspaceSelection,
handleWorktreeCreated,
+ manualSort,
+ parentGestureRef,
+ dragGestureHostActive,
handleWorkspaceReorder,
hostBadgeByServerId,
supportsMultiplicityByServerId,
@@ -2336,8 +2383,6 @@ function ProjectModeList({
onToggleWorkspacePin,
onWorkspacePress,
onToggleProjectCollapsed,
- parentGestureRef,
- dragGestureHostActive,
projectIconByProjectViewKey,
selectionEnabled,
shortcutIndexByWorkspaceKey,
diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx
index be5a66ff6f..d6483a5519 100644
--- a/packages/app/src/components/sidebar/display-preferences/menu.tsx
+++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx
@@ -167,7 +167,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
const hosts = useHosts();
// `allProjects`, never `projects`: the model's `projects` is already filtered, so a picker fed
// from it would lose the row that undoes the filter as soon as the filter narrowed to one.
- const { allProjects, resolvedProjectFilters } = useSidebarModel();
+ const { allProjects, resolvedProjectFilters, workspaceSortSupported } = useSidebarModel();
const { labels } = useWorkspaceLabelProjection();
const [managerOpen, setManagerOpen] = useState(false);
const openManager = useCallback(() => setManagerOpen(true), []);
@@ -240,6 +240,16 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
},
];
+ if (preferences.grouping === "project") {
+ definitions.push({
+ id: "sort",
+ title: t("sidebar.display.sort.label"),
+ content: (
+
+ ),
+ });
+ }
+
if (showHostFilter) {
definitions.push({
id: "hostFilter",
@@ -273,6 +283,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
}, [
t,
preferences,
+ workspaceSortSupported,
hosts,
showHostFilter,
showProjectFilter,
@@ -308,6 +319,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
>
{t("sidebar.display.grouping.label")}
+ {preferences.grouping === "project" ? (
+
+ {t("sidebar.display.sort.label")}
+
+ ) : null}
setWorkspaceSort("manual"), [setWorkspaceSort]);
+ const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]);
+ const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]);
+ return (
+ <>
+
+ {automaticSortSupported ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+ >
+ );
+}
+
/**
* Every label you could filter by, wherever it lives, one row each.
*
diff --git a/packages/app/src/components/sidebar/display-preferences/model.ts b/packages/app/src/components/sidebar/display-preferences/model.ts
index 68d347a258..a2ef3212e0 100644
--- a/packages/app/src/components/sidebar/display-preferences/model.ts
+++ b/packages/app/src/components/sidebar/display-preferences/model.ts
@@ -1,3 +1,4 @@
+import type { WorkspaceSort } from "@/projects/workspace-structure";
import { useCallback, useMemo } from "react";
import {
useAppSettings,
@@ -19,6 +20,8 @@ export interface SidebarDisplayPreferences {
showBackground: boolean;
toggleBackground: () => void;
grouping: SidebarGroupMode;
+ workspaceSort: WorkspaceSort;
+ setWorkspaceSort: (sort: WorkspaceSort) => void;
setGrouping: (mode: SidebarGroupMode) => void;
titleSource: WorkspaceTitleSource;
setTitleSource: (source: WorkspaceTitleSource) => void;
@@ -52,6 +55,8 @@ export interface SidebarDisplayPreferences {
export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
const showBackground = useSidebarViewStore((state) => state.showBackground);
const toggleBackground = useSidebarViewStore((state) => state.toggleBackground);
+ const workspaceSort = useSidebarViewStore((state) => state.workspaceSort);
+ const setWorkspaceSort = useSidebarViewStore((state) => state.setWorkspaceSort);
const grouping = useSidebarViewStore((state) => state.groupMode);
const setGrouping = useSidebarViewStore((state) => state.setGroupMode);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
@@ -111,6 +116,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
showBackground,
toggleBackground,
grouping,
+ workspaceSort,
+ setWorkspaceSort,
setGrouping,
titleSource: workspaceTitleSource,
setTitleSource,
@@ -134,6 +141,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
showBackground,
toggleBackground,
grouping,
+ workspaceSort,
+ setWorkspaceSort,
setGrouping,
workspaceTitleSource,
setTitleSource,
diff --git a/packages/app/src/components/sidebar/sidebar-group-visibility.ts b/packages/app/src/components/sidebar/sidebar-group-visibility.ts
new file mode 100644
index 0000000000..ce9f47671c
--- /dev/null
+++ b/packages/app/src/components/sidebar/sidebar-group-visibility.ts
@@ -0,0 +1,21 @@
+import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
+
+export type SidebarGroupVisibilityPolicy =
+ | { kind: "pinned" }
+ | { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket };
+
+export function selectSidebarGroupVisibility(
+ items: readonly T[],
+ expanded: boolean,
+ policy: SidebarGroupVisibilityPolicy,
+) {
+ const limit = policy.kind === "pinned" ? 20 : 7;
+ const baseline = items.filter(
+ (item, index) =>
+ index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"),
+ );
+ return {
+ visibleItems: expanded ? items.slice() : baseline,
+ canToggle: baseline.length < items.length,
+ };
+}
diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx
index ec834aee19..cfa69428f8 100644
--- a/packages/app/src/components/sidebar/sidebar-model.tsx
+++ b/packages/app/src/components/sidebar/sidebar-model.tsx
@@ -134,7 +134,7 @@ export function SidebarModelProvider({
visibleWorkspaceKeys.has(workspace.workspaceKey),
);
return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0)
- ? [{ ...project, workspaces }]
+ ? [workspaces.length === project.workspaces.length ? project : { ...project, workspaces }]
: [];
});
}
diff --git a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx
index a1481dcff9..0f7e8c24ca 100644
--- a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx
+++ b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx
@@ -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 (
workspace.statusBucket,
+};
+
function StatusGroupRows({
group,
collapsed,
@@ -345,7 +350,7 @@ function StatusGroupRows({
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
- } = useLimitedSidebarGroup(group.rows);
+ } = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY);
return (
@@ -507,7 +512,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
onToggleWorkspacePin,
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
- onWorkspacePress,
drag,
isDragging = false,
dragHandleProps,
@@ -538,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 (
(items: readonly T[]) {
+export function useLimitedSidebarGroup(
+ items: readonly T[],
+ policy: SidebarGroupVisibilityPolicy = PINNED_POLICY,
+) {
const [expanded, setExpanded] = useState(false);
- const visibleItems = useMemo(
- () => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)),
- [expanded, items],
+ const visibility = useMemo(
+ () => selectSidebarGroupVisibility(items, expanded, policy),
+ [expanded, items, policy],
);
- const canToggle = items.length > INITIAL_VISIBLE_ITEMS;
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
- return { visibleItems, expanded, canToggle, toggleExpanded };
+ return { ...visibility, expanded, toggleExpanded };
}
diff --git a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts
index 8e3a508b3b..f3de17b230 100644
--- a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts
+++ b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef } from "react";
-import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
+import type { GestureResponderEvent } from "react-native";
import * as Haptics from "expo-haptics";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import type { useContextMenu } from "@/components/ui/context-menu";
export function useLongPressDragInteraction(input: {
- drag: () => void;
+ drag?: () => void;
menuController: ReturnType | null;
}) {
const { drag, menuController } = input;
@@ -36,10 +36,9 @@ export function useLongPressDragInteraction(input: {
if (!menuController || !touchStartRef.current) {
return;
}
- const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
menuController.setAnchorRect({
x: touchStartRef.current.x,
- y: touchStartRef.current.y + statusBarHeight,
+ y: touchStartRef.current.y,
width: 0,
height: 0,
});
@@ -60,7 +59,7 @@ export function useLongPressDragInteraction(input: {
const armTimers = useCallback(() => {
clearTimers();
- if (platformIsWeb) {
+ if (platformIsWeb || !drag) {
return;
}
diff --git a/packages/app/src/components/status-icon.tsx b/packages/app/src/components/status-icon.tsx
new file mode 100644
index 0000000000..afa7f4b078
--- /dev/null
+++ b/packages/app/src/components/status-icon.tsx
@@ -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 (
+
+ {children}
+ {isRunning ? (
+
+
+
+ ) : null}
+ {statusDotColor ? : null}
+ {showNeedsInputAlert ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+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,
+ },
+}));
diff --git a/packages/app/src/components/terminal-copy-paste-actions.tsx b/packages/app/src/components/terminal-copy-paste-actions.tsx
index 26bba48aad..bf5f86eedb 100644
--- a/packages/app/src/components/terminal-copy-paste-actions.tsx
+++ b/packages/app/src/components/terminal-copy-paste-actions.tsx
@@ -3,17 +3,18 @@ import { Pressable, Text, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
export interface TerminalPasteActionProps {
- hasClipboardText: boolean;
+ /** Clipboard holds text or an image, so a paste attempt can do something. */
+ hasClipboardContent: boolean;
onPaste: () => void;
}
-export function TerminalPasteAction({ hasClipboardText, onPaste }: TerminalPasteActionProps) {
+export function TerminalPasteAction({ hasClipboardContent, onPaste }: TerminalPasteActionProps) {
return (
diff --git a/packages/app/src/components/terminal-emulator-contract.ts b/packages/app/src/components/terminal-emulator-contract.ts
index 3575e21500..c4c7080b83 100644
--- a/packages/app/src/components/terminal-emulator-contract.ts
+++ b/packages/app/src/components/terminal-emulator-contract.ts
@@ -12,6 +12,7 @@ import type {
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
+import type { TerminalFile } from "../terminal/file-paste";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
@@ -75,4 +76,5 @@ export interface TerminalEmulatorProps {
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
+ onFilePaste?: (file: Promise) => void;
}
diff --git a/packages/app/src/components/terminal-emulator-webview.native.tsx b/packages/app/src/components/terminal-emulator-webview.native.tsx
deleted file mode 100644
index dbaa0175d4..0000000000
--- a/packages/app/src/components/terminal-emulator-webview.native.tsx
+++ /dev/null
@@ -1,715 +0,0 @@
-import {
- useCallback,
- useEffect,
- useImperativeHandle,
- useMemo,
- useRef,
- useState,
- type ComponentProps,
-} from "react";
-import {
- Keyboard,
- StyleSheet,
- View,
- type GestureResponderEvent,
- type StyleProp,
- type ViewStyle,
-} from "react-native";
-import { WebView, type WebViewMessageEvent } from "react-native-webview";
-import type { ITheme } from "@xterm/xterm";
-import type { TerminalState } from "@getpaseo/protocol/messages";
-import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
-import type {
- TerminalOutputData,
- TerminalFindResult,
-} from "../terminal/runtime/terminal-emulator-runtime";
-import type {
- TerminalLocalFileLinkSource,
- TerminalLocalFileLinkTarget,
-} from "../terminal/local-links/terminal-local-link-provider";
-import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
-import type { PendingTerminalModifiers } from "../utils/terminal-keys";
-import { openExternalUrl } from "../utils/open-external-url";
-import type { TerminalEmulatorHandle, TerminalEmulatorProps } from "./terminal-emulator-contract";
-import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing";
-import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness";
-
-type BridgeInboundMessage =
- | {
- type: "mount";
- streamKey: string;
- initialSnapshot: TerminalState | null;
- scrollbackLines: number;
- theme: ITheme;
- fontFamily?: string;
- fontSize?: number;
- pendingModifiers: PendingTerminalModifiers;
- swipeGesturesEnabled: boolean;
- }
- | { type: "unmount"; streamKey: string }
- | { type: "writeOutput"; streamKey: string; text: string }
- | { type: "restoreOutput"; streamKey: string; text: string }
- | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
- | { type: "paste"; streamKey: string; text: string }
- | { type: "clear"; streamKey: string }
- | { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" }
- | { type: "clearFind"; streamKey: string }
- | { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } }
- | { type: "focus"; streamKey: string; forceRefocus?: boolean }
- | { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean }
- | { type: "setTheme"; streamKey: string; theme: ITheme }
- | { type: "setScrollback"; streamKey: string; lines: number }
- | { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number }
- | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
- | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
- | {
- type: "resolveLocalFileLinkResponse";
- streamKey: string;
- requestId: number;
- target: TerminalLocalFileLinkTarget | null;
- };
-
-type BridgeOutboundMessage =
- | { type: "bridgeReady" }
- | { type: "rendererReady"; streamKey: string; isReady: boolean }
- | { type: "input"; streamKey: string; data: string }
- | { type: "findRequest"; streamKey: string }
- | { type: "findResult"; streamKey: string; result: TerminalFindResult }
- | {
- type: "resize";
- streamKey: string;
- rows: number;
- cols: number;
- shouldClaim?: boolean;
- forceClaim?: boolean;
- }
- | {
- type: "terminalKey";
- streamKey: string;
- key: string;
- ctrl: boolean;
- shift: boolean;
- alt: boolean;
- meta: boolean;
- }
- | { type: "pendingModifiersConsumed"; streamKey: string }
- | { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
- | { type: "openExternalUrl"; streamKey: string; url: string }
- | {
- type: "resolveLocalFileLink";
- streamKey: string;
- requestId: number;
- source: TerminalLocalFileLinkSource;
- }
- | {
- type: "openLocalFileLink";
- streamKey: string;
- target: TerminalLocalFileLinkTarget;
- disposition: "main" | "side";
- }
- | { type: "swipeLeft"; streamKey: string }
- | { type: "swipeRight"; streamKey: string }
- | { type: "debug"; message: string; details?: unknown };
-
-const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml };
-const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"];
-const BRIDGE_READY_TIMEOUT_MS = 2_500;
-const RENDERER_READY_TIMEOUT_MS = 2_500;
-const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8;
-type WebViewProps = ComponentProps;
-interface PendingTerminalTap {
- startX: number;
- startY: number;
- moved: boolean;
-}
-
-function buildThemeKey(theme: ITheme): string {
- return JSON.stringify(theme);
-}
-
-function serializeForInjectedJavaScript(message: BridgeInboundMessage): string {
- return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
-}
-
-function createMountMessage(input: {
- streamKey: string;
- initialSnapshot: TerminalState | null;
- scrollbackLines: number;
- theme: ITheme;
- fontFamily?: string;
- fontSize?: number;
- pendingModifiers: PendingTerminalModifiers;
- swipeGesturesEnabled: boolean;
-}): BridgeInboundMessage {
- return {
- type: "mount",
- streamKey: input.streamKey,
- initialSnapshot: input.initialSnapshot,
- scrollbackLines: input.scrollbackLines,
- theme: input.theme,
- fontFamily: input.fontFamily,
- fontSize: input.fontSize,
- pendingModifiers: input.pendingModifiers,
- swipeGesturesEnabled: input.swipeGesturesEnabled,
- };
-}
-
-export default function WebViewTerminalEmulator({
- ref,
- streamKey,
- testId = "terminal-surface",
- xtermTheme = {
- background: "#0b0b0b",
- foreground: "#e6e6e6",
- cursor: "#e6e6e6",
- },
- scrollbackLines,
- fontFamily,
- fontSize,
- swipeGesturesEnabled = false,
- onSwipeLeft,
- onSwipeRight,
- initialSnapshot = null,
- onFindRequest,
- onFindResult,
- onInput,
- onFocus,
- onResize,
- onTerminalKey,
- onPendingModifiersConsumed,
- onInputModeChange,
- onResolveLocalFileLink,
- onOpenLocalFileLink,
- onRendererReadyChange,
- pendingModifiers = { ctrl: false, shift: false, alt: false },
- focusRequestToken = 0,
- resizeRequestToken = 0,
-}: TerminalEmulatorProps) {
- const webViewRef = useRef(null);
- const [webViewEpoch, setWebViewEpoch] = useState(0);
- const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0);
- const bridgeReadyRef = useRef(false);
- const bridgeReadyVersionRef = useRef(0);
- const rendererReadyVersionRef = useRef(0);
- const pendingMessagesRef = useRef([]);
- const outputDecoderRef = useRef(new TextDecoder());
- const mountRequestedStreamKeyRef = useRef(null);
- const rendererReadyStreamKeyRef = useRef(null);
- const bridgeReadyTimeoutRef = useRef | null>(null);
- const rendererReadyTimeoutRef = useRef | null>(null);
- const pendingTapRef = useRef(null);
- const mountConfigRef = useRef({
- streamKey,
- initialSnapshot,
- scrollbackLines,
- theme: xtermTheme,
- fontFamily,
- fontSize,
- pendingModifiers,
- swipeGesturesEnabled,
- });
- mountConfigRef.current = {
- streamKey,
- initialSnapshot,
- scrollbackLines,
- theme: xtermTheme,
- fontFamily,
- fontSize,
- pendingModifiers,
- swipeGesturesEnabled,
- };
- const callbacksRef = useRef({
- onFindRequest,
- onFindResult,
- onInput,
- onFocus,
- onResize,
- onTerminalKey,
- onPendingModifiersConsumed,
- onInputModeChange,
- onRendererReadyChange,
- onResolveLocalFileLink,
- onOpenLocalFileLink,
- onSwipeLeft,
- onSwipeRight,
- });
- callbacksRef.current = {
- onFindRequest,
- onFindResult,
- onInput,
- onFocus,
- onResize,
- onTerminalKey,
- onPendingModifiersConsumed,
- onInputModeChange,
- onRendererReadyChange,
- onResolveLocalFileLink,
- onOpenLocalFileLink,
- onSwipeLeft,
- onSwipeRight,
- };
-
- const clearBridgeReadyTimeout = useCallback(() => {
- if (bridgeReadyTimeoutRef.current === null) return;
- clearTimeout(bridgeReadyTimeoutRef.current);
- bridgeReadyTimeoutRef.current = null;
- }, []);
-
- const clearRendererReadyTimeout = useCallback(() => {
- if (rendererReadyTimeoutRef.current === null) return;
- clearTimeout(rendererReadyTimeoutRef.current);
- rendererReadyTimeoutRef.current = null;
- }, []);
-
- const resetWebViewDocument = useCallback(() => {
- clearBridgeReadyTimeout();
- clearRendererReadyTimeout();
- bridgeReadyRef.current = false;
- pendingMessagesRef.current = [];
- mountRequestedStreamKeyRef.current = null;
- rendererReadyStreamKeyRef.current = null;
- callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
- setWebViewEpoch((value) => value + 1);
- }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]);
-
- const scheduleBridgeReadyWatchdog = useCallback(() => {
- clearBridgeReadyTimeout();
- const expectedBridgeReadyVersion = bridgeReadyVersionRef.current;
- bridgeReadyTimeoutRef.current = setTimeout(() => {
- bridgeReadyTimeoutRef.current = null;
- if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) {
- return;
- }
- resetWebViewDocument();
- }, BRIDGE_READY_TIMEOUT_MS);
- }, [clearBridgeReadyTimeout, resetWebViewDocument]);
-
- const scheduleRendererReadyWatchdog = useCallback(() => {
- clearRendererReadyTimeout();
- const expectedRendererReadyVersion = rendererReadyVersionRef.current;
- rendererReadyTimeoutRef.current = setTimeout(() => {
- rendererReadyTimeoutRef.current = null;
- if (
- !shouldResetForMissingRendererReady({
- expectedReadyVersion: expectedRendererReadyVersion,
- currentReadyVersion: rendererReadyVersionRef.current,
- expectedStreamKey: streamKey,
- mountRequestedStreamKey: mountRequestedStreamKeyRef.current,
- rendererReadyStreamKey: rendererReadyStreamKeyRef.current,
- })
- ) {
- return;
- }
- resetWebViewDocument();
- }, RENDERER_READY_TIMEOUT_MS);
- }, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]);
-
- const flushPendingMessages = useCallback(() => {
- if (!bridgeReadyRef.current || !webViewRef.current) return;
- const pending = pendingMessagesRef.current.splice(0);
- for (const message of pending) {
- const payload = serializeForInjectedJavaScript(message);
- webViewRef.current.injectJavaScript(
- `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
- );
- }
- }, []);
-
- const sendToWebView = useCallback((message: BridgeInboundMessage) => {
- if (!bridgeReadyRef.current || !webViewRef.current) {
- pendingMessagesRef.current.push(message);
- return;
- }
- const payload = serializeForInjectedJavaScript(message);
- webViewRef.current.injectJavaScript(
- `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
- );
- }, []);
-
- useImperativeHandle(
- ref,
- (): TerminalEmulatorHandle => ({
- find: {
- setWidgetSize: (size) => sendToWebView({ type: "findWidgetSize", streamKey, size }),
- search: (query, direction) => sendToWebView({ type: "find", streamKey, query, direction }),
- clear: () => sendToWebView({ type: "clearFind", streamKey }),
- },
- writeOutput: (data: TerminalOutputData) => {
- const output = outputDecoderRef.current.decode(data, { stream: true });
- if (output.length === 0) {
- return;
- }
- sendToWebView({ type: "writeOutput", streamKey, text: output });
- },
- restoreOutput: (data: TerminalOutputData) => {
- outputDecoderRef.current.decode();
- const text = outputDecoderRef.current.decode(data, { stream: false });
- if (text.length === 0) {
- return;
- }
- sendToWebView({ type: "restoreOutput", streamKey, text });
- },
- renderSnapshot: (state: TerminalState | null) => {
- outputDecoderRef.current.decode();
- sendToWebView({ type: "renderSnapshot", streamKey, state });
- },
- paste: (text: string) => {
- sendToWebView({ type: "paste", streamKey, text });
- },
- copySelection: async () => "",
- clear: () => {
- outputDecoderRef.current.decode();
- sendToWebView({ type: "clear", streamKey });
- },
- claimSize: () => {
- sendToWebView({ type: "resize", streamKey, forceClaim: true });
- },
- showKeyboard: () => {
- sendToWebView({ type: "focus", streamKey, forceRefocus: true });
- webViewRef.current?.requestFocus();
- },
- blur: () => {
- webViewRef.current?.injectJavaScript(
- "window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;",
- );
- Keyboard.dismiss();
- },
- }),
- [sendToWebView, streamKey],
- );
-
- useEffect(() => {
- outputDecoderRef.current.decode();
- }, [streamKey]);
-
- useEffect(() => {
- if (bridgeReadyVersion <= 0) return;
- const mountMessage = createMountMessage(mountConfigRef.current);
- mountRequestedStreamKeyRef.current = streamKey;
- rendererReadyStreamKeyRef.current = null;
- sendToWebView(mountMessage);
- flushPendingMessages();
- scheduleRendererReadyWatchdog();
- }, [
- bridgeReadyVersion,
- flushPendingMessages,
- scheduleRendererReadyWatchdog,
- sendToWebView,
- streamKey,
- ]);
-
- const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]);
- useEffect(() => {
- if (!mountRequestedStreamKeyRef.current) return;
- sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme });
- }, [sendToWebView, streamKey, themeKey, xtermTheme]);
-
- useEffect(() => {
- if (!mountRequestedStreamKeyRef.current) return;
- sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines });
- }, [scrollbackLines, sendToWebView, streamKey]);
-
- useEffect(() => {
- if (!mountRequestedStreamKeyRef.current) return;
- sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize });
- }, [fontFamily, fontSize, sendToWebView, streamKey]);
-
- useEffect(() => {
- if (!mountRequestedStreamKeyRef.current) return;
- sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers });
- }, [pendingModifiers, sendToWebView, streamKey]);
-
- useEffect(() => {
- if (!mountRequestedStreamKeyRef.current) return;
- sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled });
- }, [sendToWebView, streamKey, swipeGesturesEnabled]);
-
- useEffect(() => {
- if (focusRequestToken <= 0) return;
- sendToWebView({ type: "resize", streamKey, forceClaim: true });
- sendToWebView({ type: "focus", streamKey });
- webViewRef.current?.requestFocus();
- }, [focusRequestToken, sendToWebView, streamKey]);
-
- useEffect(() => {
- if (resizeRequestToken <= 0) return;
- sendToWebView({ type: "resize", streamKey, forceClaim: false, shouldClaim: false });
- }, [resizeRequestToken, sendToWebView, streamKey]);
-
- useEffect(() => {
- return () => {
- if (mountRequestedStreamKeyRef.current) {
- const previousStreamKey = mountRequestedStreamKeyRef.current;
- callbacksRef.current.onRendererReadyChange?.({
- streamKey: previousStreamKey,
- isReady: false,
- });
- sendToWebView({ type: "unmount", streamKey: previousStreamKey });
- }
- bridgeReadyRef.current = false;
- pendingMessagesRef.current = [];
- mountRequestedStreamKeyRef.current = null;
- rendererReadyStreamKeyRef.current = null;
- clearBridgeReadyTimeout();
- clearRendererReadyTimeout();
- };
- }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]);
-
- const handleLifecycleMessage = useCallback(
- (message: BridgeOutboundMessage): boolean => {
- if (message.type === "bridgeReady") {
- bridgeReadyRef.current = true;
- bridgeReadyVersionRef.current += 1;
- clearBridgeReadyTimeout();
- setBridgeReadyVersion((value) => value + 1);
- return true;
- }
- if (message.type === "rendererReady") {
- if (message.streamKey === mountRequestedStreamKeyRef.current) {
- rendererReadyStreamKeyRef.current = message.isReady ? message.streamKey : null;
- if (message.isReady) {
- rendererReadyVersionRef.current += 1;
- clearRendererReadyTimeout();
- }
- }
- callbacksRef.current.onRendererReadyChange?.({
- streamKey: message.streamKey,
- isReady: message.isReady,
- });
- return true;
- }
- return false;
- },
- [clearBridgeReadyTimeout, clearRendererReadyTimeout],
- );
-
- const resolveLocalFileLink = useCallback(
- async (message: Extract) => {
- try {
- const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ??
- null);
- sendToWebView({
- type: "resolveLocalFileLinkResponse",
- streamKey: message.streamKey,
- requestId: message.requestId,
- target,
- });
- } catch {
- sendToWebView({
- type: "resolveLocalFileLinkResponse",
- streamKey: message.streamKey,
- requestId: message.requestId,
- target: null,
- });
- }
- },
- [sendToWebView],
- );
-
- const handleTerminalMessage = useCallback(
- (
- message: Exclude,
- ) => {
- if (message.type === "resolveLocalFileLink") {
- void resolveLocalFileLink(message);
- return;
- }
- if (message.type === "openLocalFileLink") {
- callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition);
- return;
- }
- switch (message.type) {
- case "input":
- callbacksRef.current.onInput?.(message.data);
- break;
- case "resize":
- callbacksRef.current.onResize?.({
- rows: message.rows,
- cols: message.cols,
- shouldClaim: message.shouldClaim !== false,
- forceClaim: message.forceClaim,
- });
- break;
- case "terminalKey":
- callbacksRef.current.onTerminalKey?.({
- key: message.key,
- ctrl: message.ctrl,
- shift: message.shift,
- alt: message.alt,
- meta: message.meta,
- });
- break;
- case "pendingModifiersConsumed":
- callbacksRef.current.onPendingModifiersConsumed?.();
- break;
- case "inputModeChange":
- callbacksRef.current.onInputModeChange?.(message.state);
- break;
- case "openExternalUrl":
- void openExternalUrl(message.url);
- break;
- case "swipeLeft":
- callbacksRef.current.onSwipeLeft?.();
- break;
- case "swipeRight":
- callbacksRef.current.onSwipeRight?.();
- break;
- case "debug":
- break;
- }
- },
- [resolveLocalFileLink],
- );
-
- const handleMessage = useCallback(
- (event: WebViewMessageEvent) => {
- let message: BridgeOutboundMessage;
- try {
- message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage;
- } catch {
- return;
- }
-
- if (message.type === "bridgeReady" || message.type === "rendererReady") {
- handleLifecycleMessage(message);
- return;
- }
- if (
- !shouldHandleTerminalBridgeMessage(message, {
- desiredStreamKey: streamKey,
- mountedStreamKey: mountRequestedStreamKeyRef.current,
- })
- ) {
- return;
- }
- if (message.type === "findRequest") {
- callbacksRef.current.onFindRequest?.();
- return;
- }
- if (message.type === "findResult") {
- callbacksRef.current.onFindResult?.(message.result);
- return;
- }
- handleTerminalMessage(message);
- },
- [handleLifecycleMessage, handleTerminalMessage, streamKey],
- );
-
- const handleLoadStart = useCallback>(() => {
- bridgeReadyRef.current = false;
- mountRequestedStreamKeyRef.current = null;
- rendererReadyStreamKeyRef.current = null;
- scheduleBridgeReadyWatchdog();
- }, [scheduleBridgeReadyWatchdog]);
-
- const handleContentProcessDidTerminate = useCallback<
- NonNullable
- >(() => {
- resetWebViewDocument();
- }, [resetWebViewDocument]);
-
- const handleRenderProcessGone = useCallback<
- NonNullable
- >(() => {
- resetWebViewDocument();
- }, [resetWebViewDocument]);
-
- const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => {
- pendingTapRef.current = {
- startX: event.nativeEvent.pageX,
- startY: event.nativeEvent.pageY,
- moved: false,
- };
- }, []);
-
- const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => {
- const pendingTap = pendingTapRef.current;
- if (!pendingTap) return;
- const dx = event.nativeEvent.pageX - pendingTap.startX;
- const dy = event.nativeEvent.pageY - pendingTap.startY;
- if (
- Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX ||
- Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX
- ) {
- pendingTap.moved = true;
- }
- }, []);
-
- const handleWebViewTouchEnd = useCallback(() => {
- const pendingTap = pendingTapRef.current;
- pendingTapRef.current = null;
- if (!pendingTap || pendingTap.moved) {
- return;
- }
- webViewRef.current?.requestFocus();
- callbacksRef.current.onFocus?.();
- sendToWebView({ type: "focus", streamKey, forceRefocus: true });
- }, [sendToWebView, streamKey]);
-
- const handleWebViewTouchCancel = useCallback(() => {
- pendingTapRef.current = null;
- }, []);
-
- const backgroundColor = xtermTheme.background ?? "#0b0b0b";
- const rootStyle = useMemo>(
- () => [styles.root, { backgroundColor }],
- [backgroundColor],
- );
- const webViewStyle = useMemo>(
- () => [styles.webView, { backgroundColor }],
- [backgroundColor],
- );
- const webViewContainerStyle = useMemo>(
- () => [styles.webViewContainer, { backgroundColor }],
- [backgroundColor],
- );
-
- return (
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- root: {
- flex: 1,
- minHeight: 0,
- minWidth: 0,
- overflow: "hidden",
- },
- webView: {
- flex: 1,
- },
- webViewContainer: {
- flex: 1,
- },
-});
diff --git a/packages/app/src/components/terminal-emulator.native.tsx b/packages/app/src/components/terminal-emulator.native.tsx
index 052d7a02f0..0635291c4c 100644
--- a/packages/app/src/components/terminal-emulator.native.tsx
+++ b/packages/app/src/components/terminal-emulator.native.tsx
@@ -2,10 +2,8 @@ import { useEffect } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
-import { useAppSettings } from "../hooks/use-settings";
import { resolveTerminalRendererSelection } from "../terminal/native-renderer/terminal-renderer-capability";
import NativeGridTerminalEmulator from "./terminal-emulator-native-grid.native";
-import WebViewTerminalEmulator from "./terminal-emulator-webview.native";
import type { TerminalEmulatorProps } from "./terminal-emulator-contract";
export type { TerminalEmulatorHandle } from "./terminal-emulator-contract";
@@ -25,10 +23,7 @@ function UnsupportedNativeTerminal({ streamKey, onRendererReadyChange }: Termina
}
export default function TerminalEmulator(props: TerminalEmulatorProps) {
- const { settings } = useAppSettings();
- const { useLegacyTerminalRenderer } = settings;
const rendererSelection = resolveTerminalRendererSelection({
- useLegacyTerminalRenderer,
supportsTerminalInputModeReplay: props.supportsTerminalInputModeReplay,
});
@@ -36,10 +31,7 @@ export default function TerminalEmulator(props: TerminalEmulatorProps) {
return ;
}
- const Renderer =
- rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator;
-
- return ;
+ return ;
}
const styles = StyleSheet.create((theme) => ({
diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx
index be12355bea..3d272fb75e 100644
--- a/packages/app/src/components/terminal-emulator.tsx
+++ b/packages/app/src/components/terminal-emulator.tsx
@@ -11,9 +11,7 @@ import {
useImperativeHandle,
useMemo,
useRef,
- useState,
type CSSProperties,
- type DragEvent as ReactDragEvent,
type MouseEvent as ReactMouseEvent,
type Ref,
} from "react";
@@ -29,6 +27,7 @@ import {
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
+import type { TerminalFile } from "../terminal/file-paste";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
@@ -37,13 +36,6 @@ import type { TerminalClipboardWriter } from "../terminal/native-renderer/termin
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
-import {
- extractTerminalDropPaths,
- isTerminalDragLeaveOutside,
- isTerminalFileDrag,
- prepareDroppedPathsForTerminal,
-} from "../terminal/drop/terminal-file-drop";
-import { getDesktopHost } from "@/desktop/host";
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
@@ -145,6 +137,7 @@ interface TerminalEmulatorProps {
disposition: "main" | "side",
) => Promise | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
+ onFilePaste?: (file: Promise) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
@@ -191,6 +184,7 @@ export default function TerminalEmulator({
onResolveLocalFileLink,
onOpenLocalFileLink,
onRendererReadyChange,
+ onFilePaste,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
@@ -220,6 +214,7 @@ export default function TerminalEmulator({
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
+ onFilePaste,
});
mountCallbacksRef.current = {
onFindRequest,
@@ -231,13 +226,12 @@ export default function TerminalEmulator({
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
+ onFilePaste,
};
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
const pendingModifiersRef = useRef(pendingModifiers);
pendingModifiersRef.current = pendingModifiers;
- const [isDropActive, setIsDropActive] = useState(false);
- const dropActiveTimeoutRef = useRef | null>(null);
const domBridgeRef = useRef(null);
const pasteText = useCallback((text: string) => {
@@ -506,11 +500,13 @@ export default function TerminalEmulator({
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
+ onFilePaste,
onOpenExternalUrl: openExternalUrl,
},
});
}, [
onFindRequest,
+ onFilePaste,
onFindResult,
onInput,
onInputModeChange,
@@ -582,111 +578,6 @@ export default function TerminalEmulator({
[showTerminalContextMenu],
);
- const clearDropActiveTimeout = useCallback(() => {
- if (dropActiveTimeoutRef.current === null) {
- return;
- }
- clearTimeout(dropActiveTimeoutRef.current);
- dropActiveTimeoutRef.current = null;
- }, []);
-
- const clearTerminalDropActive = useCallback(() => {
- clearDropActiveTimeout();
- setIsDropActive(false);
- }, [clearDropActiveTimeout]);
-
- const keepTerminalDropActive = useCallback(() => {
- clearDropActiveTimeout();
- setIsDropActive(true);
- dropActiveTimeoutRef.current = setTimeout(() => {
- dropActiveTimeoutRef.current = null;
- setIsDropActive(false);
- }, 180);
- }, [clearDropActiveTimeout]);
-
- useEffect(() => {
- const root = rootRef.current;
- if (!root) {
- return () => {};
- }
-
- const handleDragEnter = (event: DragEvent) => {
- if (!isTerminalFileDrag(event.dataTransfer)) {
- return;
- }
- event.preventDefault();
- event.stopPropagation();
- keepTerminalDropActive();
- };
-
- const handleDragOver = (event: DragEvent) => {
- if (!isTerminalFileDrag(event.dataTransfer)) {
- return;
- }
- event.preventDefault();
- event.stopPropagation();
- if (event.dataTransfer) {
- event.dataTransfer.dropEffect = "copy";
- }
- keepTerminalDropActive();
- };
-
- const handleDrop = (event: DragEvent) => {
- if (!isTerminalFileDrag(event.dataTransfer)) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
- clearTerminalDropActive();
-
- const bridge = getDesktopHost();
- const paths = extractTerminalDropPaths(event.dataTransfer, bridge);
- if (paths.length === 0) {
- return;
- }
-
- runtimeRef.current?.focus();
- mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths, bridge));
- };
-
- root.addEventListener("dragenter", handleDragEnter, { capture: true });
- root.addEventListener("dragover", handleDragOver, { capture: true });
- root.addEventListener("drop", handleDrop, { capture: true });
- window.addEventListener("dragend", clearTerminalDropActive);
- window.addEventListener("drop", clearTerminalDropActive);
-
- return () => {
- root.removeEventListener("dragenter", handleDragEnter, { capture: true });
- root.removeEventListener("dragover", handleDragOver, { capture: true });
- root.removeEventListener("drop", handleDrop, { capture: true });
- window.removeEventListener("dragend", clearTerminalDropActive);
- window.removeEventListener("drop", clearTerminalDropActive);
- clearDropActiveTimeout();
- };
- }, [clearDropActiveTimeout, clearTerminalDropActive, keepTerminalDropActive]);
-
- const handleRootDragLeave = useCallback(
- (event: ReactDragEvent) => {
- if (!isTerminalFileDrag(event.dataTransfer)) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
- if (
- !isTerminalDragLeaveOutside({
- currentTarget: event.currentTarget,
- relatedTarget: event.relatedTarget,
- })
- ) {
- return;
- }
- clearTerminalDropActive();
- },
- [clearTerminalDropActive],
- );
-
const rootDivStyle = useMemo(
() => ({
position: "relative",
@@ -702,19 +593,6 @@ export default function TerminalEmulator({
}),
[xtermTheme.background],
);
- const dropOverlayStyle = useMemo(
- () => ({
- position: "absolute",
- inset: 0,
- zIndex: 9,
- border: "1px solid rgba(78, 161, 255, 0.72)",
- backgroundColor: "rgba(78, 161, 255, 0.16)",
- opacity: isDropActive ? 1 : 0,
- pointerEvents: "none",
- transition: "opacity 120ms ease-out",
- }),
- [isDropActive],
- );
return (
);
}
diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx
index 4b37470b30..6c1561bc5b 100644
--- a/packages/app/src/components/terminal-pane.tsx
+++ b/packages/app/src/components/terminal-pane.tsx
@@ -43,7 +43,23 @@ import {
shouldShowTerminalPasteAction,
type TerminalVirtualKeyboardControl,
} from "@/terminal/runtime/terminal-virtual-keyboard";
-import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
+import { encodeTerminalPaste, pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
+import {
+ TerminalFilePasteQueue,
+ TerminalFileTooLargeError,
+ terminalFileFromBase64,
+ type TerminalFile,
+} from "@/terminal/file-paste";
+import { useToast } from "@/contexts/toast-context";
+import type { DismissToast } from "@/components/toast-host";
+import { FileDropZone } from "@/components/file-drop/file-drop-zone";
+import { useFileDrop } from "@/components/file-drop/use-file-drop";
+import type { DroppedItem } from "@/components/file-drop/types";
+import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
+import { getDesktopHost } from "@/desktop/host";
+import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
+import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
+import { toErrorMessage } from "@/utils/error-messages";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
EMPTY_FOCUS_CLAIM_STATE,
@@ -122,6 +138,24 @@ type PendingTerminalInput =
input: TerminalKeyInput;
};
+async function readNativeClipboardImage(): Promise {
+ const image = await Clipboard.getImageAsync({ format: "png" });
+ if (!image) {
+ return null;
+ }
+ return terminalFileFromBase64({
+ fileName: "clipboard.png",
+ mimeType: "image/png",
+ data: image.data.slice(image.data.indexOf(",") + 1),
+ });
+}
+
+/** Receives files dropped on the pane's FileDropZone; the pane routes them by host locality. */
+function TerminalFileDropSink({ onFiles }: { onFiles: (items: DroppedItem[]) => void }) {
+ useFileDrop({ onGenericFiles: onFiles });
+ return null;
+}
+
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
@@ -292,9 +326,47 @@ export function TerminalPane({
const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS);
const [hasSelection, setHasSelection] = useState(false);
const [hasClipboardText, setHasClipboardText] = useState(false);
+ const [hasClipboardImage, setHasClipboardImage] = useState(false);
const [isKeyboardToggleVisible, setIsKeyboardToggleVisible] = useState(false);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
+ const toast = useToast();
+ const dismissUploadingToastRef = useRef(null);
+ const showFileUploading = useStableEvent((uploading: boolean) => {
+ dismissUploadingToastRef.current?.();
+ dismissUploadingToastRef.current = uploading
+ ? toast.show(t("workspace.terminal.uploadingFile"), {
+ durationMs: null,
+ testID: "terminal-file-uploading",
+ })
+ : null;
+ });
+ const reportFileUploaded = useStableEvent(() => {
+ toast.show(t("workspace.terminal.fileUploaded"), { variant: "success" });
+ });
+ const reportFilePasteFailure = useStableEvent((error: unknown) => {
+ const reason =
+ error instanceof TerminalFileTooLargeError
+ ? t("composer.errors.fileTooLarge", { size: "50MB", fileName: error.fileName })
+ : toErrorMessage(error);
+ toast.error(t("workspace.terminal.pasteFileFailed", { reason }));
+ });
+ const [filePastes] = useState(
+ () =>
+ new TerminalFilePasteQueue({
+ onUploadingChange: showFileUploading,
+ onUploaded: reportFileUploaded,
+ onFailure: reportFilePasteFailure,
+ }),
+ );
+ useEffect(() => () => void filePastes.cancel(), [filePastes, terminalStreamKey]);
+ useEffect(() => {
+ if (!isConnected && filePastes.cancel()) {
+ reportFilePasteFailure(new Error(t("workspace.terminal.hostDisconnected")));
+ }
+ }, [filePastes, isConnected, reportFilePasteFailure, t]);
+ // Only the desktop app's own daemon can read files on this machine by path.
+ const isLocalHost = useIsLocalDaemon(serverId);
useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection);
const emulatorRef = useRef(null);
const findRef = useRef(null);
@@ -325,10 +397,15 @@ export function TerminalPane({
return;
}
try {
- const hasText = await Clipboard.hasStringAsync();
+ const [hasText, hasImage] = await Promise.all([
+ Clipboard.hasStringAsync(),
+ Clipboard.hasImageAsync(),
+ ]);
setHasClipboardText(hasText);
+ setHasClipboardImage(hasImage);
} catch {
setHasClipboardText(false);
+ setHasClipboardImage(false);
}
}, [showVirtualKeyBar]);
@@ -705,6 +782,9 @@ export function TerminalPane({
alt: boolean;
meta?: boolean;
}): boolean => {
+ if (input.ctrl && input.key.toLowerCase() === "c") {
+ filePastes.cancel();
+ }
if (!client || !terminalIdRef.current) {
enqueuePendingTerminalInput({
type: "key",
@@ -740,7 +820,7 @@ export function TerminalPane({
}
return true;
},
- [client, dispatchTerminalInputEntry, enqueuePendingTerminalInput],
+ [client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, filePastes],
);
const handleTerminalData = useCallback(
@@ -748,6 +828,10 @@ export function TerminalPane({
if (data.length === 0) {
return;
}
+ // Ctrl+C abandons file pastes that have not landed yet.
+ if (data.includes("\x03")) {
+ filePastes.cancel();
+ }
if (hasPendingTerminalModifiers(modifiers)) {
const pendingResolution = resolvePendingModifierDataInput({
@@ -793,12 +877,59 @@ export function TerminalPane({
clearPendingModifiers,
client,
dispatchTerminalInputEntry,
+ filePastes,
modifiers,
sendTerminalKey,
enqueuePendingTerminalInput,
],
);
+ const pasteTerminalText = useStableEvent((text: string) => {
+ const entry: PendingTerminalInput = {
+ type: "data",
+ data: encodeTerminalPaste({
+ text,
+ bracketedPaste: inputModeRef.current.bracketedPaste ?? false,
+ }),
+ };
+ if (!dispatchTerminalInputEntry(entry)) {
+ enqueuePendingTerminalInput(entry);
+ }
+ });
+
+ const handleFilePaste = useStableEvent((file: Promise) => {
+ filePastes.add(file, {
+ upload: async (upload) => {
+ if (!client || !isConnected) {
+ throw new Error(t("workspace.terminal.hostDisconnected"));
+ }
+ const response = await client.uploadFile(upload);
+ if (!response.file) {
+ throw new Error(response.error ?? "Upload failed");
+ }
+ return response.file.path;
+ },
+ paste: pasteTerminalText,
+ });
+ });
+
+ const handleDroppedFiles = useStableEvent((items: DroppedItem[]) => {
+ const bridge = getDesktopHost();
+ const paths: string[] = [];
+ for (const item of items) {
+ const delivery = routeTerminalDrop(item, { hostIsLocal: isLocalHost, bridge });
+ if (delivery.kind === "path") {
+ paths.push(delivery.path);
+ } else {
+ handleFilePaste(delivery.read());
+ }
+ }
+ if (paths.length > 0) {
+ pasteTerminalText(prepareDroppedPathsForTerminal(paths));
+ }
+ requestTerminalFocus();
+ });
+
const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => {
const nextSize = { rows: input.rows, cols: input.cols };
const claim = resolveTerminalResizeClaim({
@@ -876,11 +1007,15 @@ export function TerminalPane({
const handleTerminalPaste = useCallback(() => {
requestTerminalReflow();
- void pasteTerminalClipboard({
+ const image = pasteTerminalClipboard({
clipboard: { readText: () => Clipboard.getStringAsync() },
terminal: { paste: (text) => emulatorRef.current?.paste(text) },
- }).then(() => refreshClipboardAvailability());
- }, [requestTerminalReflow, refreshClipboardAvailability]);
+ })
+ .catch(() => false)
+ .then((pastedText) => (pastedText ? null : readNativeClipboardImage()));
+ handleFilePaste(image);
+ void image.catch(() => null).then(() => refreshClipboardAvailability());
+ }, [handleFilePaste, requestTerminalReflow, refreshClipboardAvailability]);
const handleTerminalCopy = useCallback(() => {
void emulatorRef.current
@@ -1032,7 +1167,7 @@ export function TerminalPane({
return showPasteAction ? (
) : null;
@@ -1065,7 +1200,8 @@ export function TerminalPane({
return (
-
+
+
) : null}
-
+
{streamError ? (
diff --git a/packages/app/src/components/terminal-webview-message-routing.test.ts b/packages/app/src/components/terminal-webview-message-routing.test.ts
deleted file mode 100644
index 7a08aa19d9..0000000000
--- a/packages/app/src/components/terminal-webview-message-routing.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing";
-
-describe("terminal WebView message routing", () => {
- it("rejects terminal messages from a stale stream", () => {
- expect(
- shouldHandleTerminalBridgeMessage(
- { type: "input", streamKey: "terminal-a" },
- { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
- ),
- ).toBe(false);
- expect(
- shouldHandleTerminalBridgeMessage(
- { type: "resize", streamKey: "terminal-a" },
- { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
- ),
- ).toBe(false);
- });
-
- it("rejects the old WebView stream while React is switching callbacks", () => {
- expect(
- shouldHandleTerminalBridgeMessage(
- { type: "input", streamKey: "terminal-a" },
- { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-a" },
- ),
- ).toBe(false);
- });
-
- it("accepts messages from the mounted stream and unscoped diagnostics", () => {
- expect(
- shouldHandleTerminalBridgeMessage(
- { type: "input", streamKey: "terminal-b" },
- { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
- ),
- ).toBe(true);
- expect(
- shouldHandleTerminalBridgeMessage(
- { type: "debug" },
- { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
- ),
- ).toBe(true);
- });
-});
diff --git a/packages/app/src/components/terminal-webview-message-routing.ts b/packages/app/src/components/terminal-webview-message-routing.ts
deleted file mode 100644
index c23c6b7244..0000000000
--- a/packages/app/src/components/terminal-webview-message-routing.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-interface TerminalBridgeMessageIdentity {
- type: string;
- streamKey?: string;
-}
-
-export function shouldHandleTerminalBridgeMessage(
- message: TerminalBridgeMessageIdentity,
- stream: {
- desiredStreamKey: string;
- mountedStreamKey: string | null;
- },
-): boolean {
- return (
- message.streamKey === undefined ||
- (message.streamKey === stream.desiredStreamKey && message.streamKey === stream.mountedStreamKey)
- );
-}
diff --git a/packages/app/src/components/terminal-webview-readiness.test.ts b/packages/app/src/components/terminal-webview-readiness.test.ts
deleted file mode 100644
index 00db851676..0000000000
--- a/packages/app/src/components/terminal-webview-readiness.test.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness";
-
-describe("legacy terminal WebView readiness", () => {
- it("resets when a requested mount never reports renderer readiness", () => {
- expect(
- shouldResetForMissingRendererReady({
- expectedReadyVersion: 0,
- currentReadyVersion: 0,
- expectedStreamKey: "terminal-a",
- mountRequestedStreamKey: "terminal-a",
- rendererReadyStreamKey: null,
- }),
- ).toBe(true);
- });
-
- it("keeps the document after the requested renderer confirms readiness", () => {
- expect(
- shouldResetForMissingRendererReady({
- expectedReadyVersion: 0,
- currentReadyVersion: 0,
- expectedStreamKey: "terminal-a",
- mountRequestedStreamKey: "terminal-a",
- rendererReadyStreamKey: "terminal-a",
- }),
- ).toBe(false);
- });
-
- it("ignores a watchdog from an earlier ready generation", () => {
- expect(
- shouldResetForMissingRendererReady({
- expectedReadyVersion: 0,
- currentReadyVersion: 1,
- expectedStreamKey: "terminal-a",
- mountRequestedStreamKey: "terminal-a",
- rendererReadyStreamKey: null,
- }),
- ).toBe(false);
- });
-
- it("does not let readiness from a stale stream satisfy the requested mount", () => {
- expect(
- shouldResetForMissingRendererReady({
- expectedReadyVersion: 0,
- currentReadyVersion: 0,
- expectedStreamKey: "terminal-b",
- mountRequestedStreamKey: "terminal-b",
- rendererReadyStreamKey: "terminal-a",
- }),
- ).toBe(true);
- });
-
- it("does not reset a newer mount from an expired watchdog", () => {
- expect(
- shouldResetForMissingRendererReady({
- expectedReadyVersion: 0,
- currentReadyVersion: 0,
- expectedStreamKey: "terminal-a",
- mountRequestedStreamKey: "terminal-b",
- rendererReadyStreamKey: null,
- }),
- ).toBe(false);
- });
-});
diff --git a/packages/app/src/components/terminal-webview-readiness.ts b/packages/app/src/components/terminal-webview-readiness.ts
deleted file mode 100644
index a0cf804156..0000000000
--- a/packages/app/src/components/terminal-webview-readiness.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export function shouldResetForMissingRendererReady(input: {
- expectedReadyVersion: number;
- currentReadyVersion: number;
- expectedStreamKey: string;
- mountRequestedStreamKey: string | null;
- rendererReadyStreamKey: string | null;
-}): boolean {
- return (
- input.currentReadyVersion === input.expectedReadyVersion &&
- input.mountRequestedStreamKey === input.expectedStreamKey &&
- input.rendererReadyStreamKey !== input.expectedStreamKey
- );
-}
diff --git a/packages/app/src/components/toast-host.test.tsx b/packages/app/src/components/toast-host.test.tsx
new file mode 100644
index 0000000000..32f1bfb8a7
--- /dev/null
+++ b/packages/app/src/components/toast-host.test.tsx
@@ -0,0 +1,35 @@
+// @vitest-environment jsdom
+
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useToastHost } from "./toast-host";
+
+describe("useToastHost", () => {
+ it("dismisses the toast that show returned the dismiss for", () => {
+ const { result } = renderHook(() => useToastHost());
+
+ let dismiss = () => {};
+ act(() => {
+ dismiss = result.current.api.show("Uploading image…", { durationMs: null });
+ });
+ expect(result.current.toast?.content).toBe("Uploading image…");
+
+ act(() => dismiss());
+ expect(result.current.toast).toBeNull();
+ });
+
+ it("leaves a newer toast in place when an older one is dismissed", () => {
+ const { result } = renderHook(() => useToastHost());
+
+ let dismissUploading = () => {};
+ act(() => {
+ dismissUploading = result.current.api.show("Uploading image…", { durationMs: null });
+ });
+ act(() => {
+ result.current.api.show("Image uploaded", { variant: "success" });
+ });
+
+ act(() => dismissUploading());
+ expect(result.current.toast?.content).toBe("Image uploaded");
+ });
+});
diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx
index 6b9009dd6a..d9c8001f50 100644
--- a/packages/app/src/components/toast-host.tsx
+++ b/packages/app/src/components/toast-host.tsx
@@ -43,8 +43,11 @@ export interface ToastState {
testID?: string;
}
+/** Dismisses the toast it was returned for; a no-op once another toast replaced it. */
+export type DismissToast = () => void;
+
export interface ToastApi {
- show: (content: ReactNode, options?: ToastShowOptions) => void;
+ show: (content: ReactNode, options?: ToastShowOptions) => DismissToast;
copied: (label?: string) => void;
error: (message: string) => void;
}
@@ -52,6 +55,7 @@ export interface ToastApi {
type ToastViewportPlacement = "app-shell" | "panel";
const DEFAULT_DURATION_MS = 2200;
+const noopDismiss: DismissToast = () => {};
const TOAST_MAX_WIDTH = 480;
const toastEntering = FadeIn.duration(140);
const toastExiting = FadeOut.duration(140);
@@ -73,10 +77,10 @@ export function useToastHost(): {
const [toast, setToast] = useState(null);
const idRef = useRef(0);
- const show = useCallback((content: ReactNode, options?: ToastShowOptions) => {
+ const show = useCallback((content: ReactNode, options?: ToastShowOptions): DismissToast => {
const nativeMessage = typeof content === "string" ? content.trim() : null;
if (!content || nativeMessage === "") {
- return;
+ return noopDismiss;
}
const variant = options?.variant ?? "default";
@@ -87,12 +91,13 @@ export function useToastHost(): {
const duration =
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
- return;
+ return noopDismiss;
}
idRef.current += 1;
+ const id = idRef.current;
setToast({
- id: idRef.current,
+ id,
content,
nativeMessage,
icon: options?.icon,
@@ -100,6 +105,7 @@ export function useToastHost(): {
durationMs,
testID: options?.testID,
});
+ return () => setToast((current) => (current?.id === id ? null : current));
}, []);
const api = useMemo(
diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx
index d67eb15f26..a10bd7b0c6 100644
--- a/packages/app/src/components/tool-call-details.tsx
+++ b/packages/app/src/components/tool-call-details.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, type ReactNode } from "react";
+import { useMemo, type ReactNode } from "react";
import {
View,
Text,
@@ -186,48 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
);
}
-interface WorktreeSetupDetailProps {
- log: string;
- branchName: string;
- worktreePath: string;
- ds: DetailStyles;
-}
-
-function WorktreeSetupDetailSection({
- log,
- branchName,
- worktreePath,
- ds,
-}: WorktreeSetupDetailProps) {
- const setupLog = log.replace(/^\n+/, "");
- const hasLog = setupLog.length > 0;
- return (
-
-
-
-
-
-
- {hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`}
-
-
-
-
-
-
- );
-}
-
function resolveSubAgentFallbackHeader(
subAgentType: string | null | undefined,
description: string | null | undefined,
@@ -678,17 +636,7 @@ function buildDetailSections(
,
];
}
- if (detail.type === "worktree_setup") {
- return [
- ,
- ];
- }
+ if (detail.type === "worktree_setup") return [];
if (detail.type === "sub_agent") {
return [
{
- return new Promise((resolve) => {
- element.measureInWindow((x, y, width, height) => {
- resolve({ x, y, width, height });
- });
- });
-}
-
interface AutocompletePopoverProps {
visible: boolean;
anchorRef: RefObject;
@@ -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 (
-
-
+
+
-
+
);
}
-
-const styles = StyleSheet.create(() => ({
- overlay: {
- position: "absolute",
- top: 0,
- right: 0,
- bottom: 0,
- left: 0,
- },
-}));
diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx
index 7401f7aac3..84d76c1083 100644
--- a/packages/app/src/components/ui/combobox.tsx
+++ b/packages/app/src/components/ui/combobox.tsx
@@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
+import { useRetainedPanelActive } from "@/components/retained-panel";
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -910,13 +911,9 @@ function resolveInitialActiveIndex(
return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
}
-type BottomSheetVisibility = ReturnType;
-
interface MobileBodyProps {
- bottomSheetRef: BottomSheetVisibility["sheetRef"];
+ isOpen: boolean;
snapPoints: string[];
- handleSheetChange: BottomSheetVisibility["handleSheetChange"];
- handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
handleIndicatorStyle: { backgroundColor: string };
titleColor: string;
title: string;
@@ -944,7 +941,22 @@ interface MobileBodyProps {
safeAreaBottom: number;
}
-function MobileComboboxBody(props: MobileBodyProps): ReactElement {
+function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
+ const active = useRetainedPanelActive();
+ if (!active) return null;
+ // The visibility tracker must leave the tree with its sheet.
+ return ;
+}
+
+function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
+ const {
+ sheetRef: bottomSheetRef,
+ handleSheetChange,
+ handleSheetDismiss,
+ } = useIsolatedBottomSheetVisibility({
+ visible: props.isOpen,
+ onClose: props.onClose,
+ });
const comboboxTitleStyle = useMemo(
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
@@ -979,13 +991,13 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
return (
{
@@ -1190,6 +1203,9 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
[props.refs, setWebOverlayScope],
);
+ // Remove the portal before a retained chat can freeze its closing update.
+ if (!active) return null;
+
const overlay = (
{
+ if (!active && isOpen) handleClose();
+ }, [active, isOpen, handleClose]);
+
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
const collisionPadding = useMemo(computeCollisionPadding, []);
@@ -1413,16 +1434,6 @@ export function Combobox({
referenceWidth,
});
- const {
- sheetRef: bottomSheetRef,
- handleSheetChange,
- handleSheetDismiss,
- } = useIsolatedBottomSheetVisibility({
- visible: isOpen,
- isEnabled: isMobile,
- onClose: handleClose,
- });
-
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption = useMemo(
@@ -1572,10 +1583,8 @@ export function Combobox({
if (isMobile) {
return (
;
+ /** 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 (
-
+
{children}
-
-
-
+ {enabled ? (
+
+
+
+ ) : null}
);
@@ -87,5 +92,6 @@ const styles = StyleSheet.create({
},
host: {
...StyleSheet.absoluteFill,
+ zIndex: 1,
},
});
diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx
index bc77ab58f4..9631dbbe9a 100644
--- a/packages/app/src/components/ui/menu/menu-overlay.tsx
+++ b/packages/app/src/components/ui/menu/menu-overlay.tsx
@@ -14,7 +14,6 @@ import {
Modal,
Platform,
Pressable,
- StatusBar,
View,
type StyleProp,
type ViewStyle,
@@ -162,16 +161,13 @@ export function useAnchoredPosition({
return undefined;
}
- // Capture status bar height synchronously before async measurement, so it cannot change
- // or read back null between the measure call and its resolution.
- const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
+ // The app draws edge-to-edge, so measureInWindow and the full-screen Modal share one
+ // coordinate space that starts at the screen top; no status bar offset applies.
void measureElement(anchorRef.current).then((rect) => {
if (cancelled) return undefined;
- // On Android with statusBarTranslucent, measureInWindow returns coordinates relative to
- // below the status bar while Modal content starts at the screen top.
- setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
+ setTriggerRect(rect);
return undefined;
});
diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx
index 72803f06d0..ae00768a34 100644
--- a/packages/app/src/components/ui/tooltip.tsx
+++ b/packages/app/src/components/ui/tooltip.tsx
@@ -18,7 +18,6 @@ import {
Platform,
Modal,
Pressable,
- StatusBar,
View,
type PressableProps,
type StyleProp,
@@ -474,11 +473,10 @@ export function TooltipContent({
return () => {};
}
- const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
void measureElement(ctx.triggerRef.current).then((rect) => {
- if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
+ if (!cancelled) setTriggerRect(rect);
return undefined;
});
diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx
index 7124fd2f0a..84e9141b6a 100644
--- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx
+++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx
@@ -31,6 +31,8 @@ function workspaceDescriptor(input: {
statusEnteredAt?: Date | null;
}): WorkspaceDescriptor {
return {
+ createdAt: null,
+ lastUserMessageAt: null,
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
@@ -86,7 +88,6 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
});
useSidebarOrderStore.setState({
projectOrder: [],
- workspaceOrderByProject: {},
});
useSidebarViewStore.setState({
groupMode: "project",
diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts
index 4ae1764a02..f810ef19c6 100644
--- a/packages/app/src/composer/actions.ts
+++ b/packages/app/src/composer/actions.ts
@@ -1,4 +1,5 @@
import type { SelectedFile } from "@/attachments/selected-file";
+import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type {
@@ -54,7 +55,7 @@ export interface ComposerSendClient {
images: Array<{ data: string; mimeType: string }>;
attachments: ReturnType["attachments"];
},
- ) => Promise;
+ ) => Promise;
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
requestId: string;
file: {
@@ -131,7 +132,7 @@ export async function uploadFileAttachments(input: {
for (const file of input.files) {
const bytes = await file.readBytes();
- if (bytes.byteLength > 50 * 1024 * 1024) {
+ if (bytes.byteLength > FILE_UPLOAD_MAX_BYTES) {
throw new Error(
i18n.t("composer.errors.fileTooLarge", { size: "50MB", fileName: file.fileName }),
);
diff --git a/packages/app/src/composer/diff-stat-pill.test.tsx b/packages/app/src/composer/diff-stat-pill.test.tsx
index 96e1e1139a..5115666994 100644
--- a/packages/app/src/composer/diff-stat-pill.test.tsx
+++ b/packages/app/src/composer/diff-stat-pill.test.tsx
@@ -11,6 +11,8 @@ const SERVER_ID = "diff-stat-pill";
const WORKSPACE_ID = "workspace";
const workspace: WorkspaceDescriptor = {
+ createdAt: null,
+ lastUserMessageAt: null,
id: WORKSPACE_ID,
projectId: "project",
projectDisplayName: "Project",
diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx
index 6ad84d632d..4776b5af3d 100644
--- a/packages/app/src/composer/editing-session/index.tsx
+++ b/packages/app/src/composer/editing-session/index.tsx
@@ -119,6 +119,20 @@ const SessionsContext = createContext(null);
const WorkspaceContext = createContext(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) => (
+
+ {children}
+
+ ),
+ [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());
diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx
index 10d670fcae..ed9ccca4f7 100644
--- a/packages/app/src/composer/index.tsx
+++ b/packages/app/src/composer/index.tsx
@@ -315,6 +315,7 @@ interface PendingFileAttachment {
}
interface RenderAttachmentTrayArgs {
+ serverId: string;
selectedAttachments: ComposerAttachment[];
pendingFiles: PendingFileAttachment[];
isComposerLocked: boolean;
@@ -331,6 +332,7 @@ interface RenderAttachmentTrayArgs {
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
const {
+ serverId,
selectedAttachments,
pendingFiles,
isComposerLocked,
@@ -343,6 +345,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu
{selectedAttachments.map((attachment, index) =>
renderComposerAttachmentPill({
+ serverId,
attachment,
index,
disabled: isComposerLocked,
@@ -393,6 +396,7 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
}
interface RenderComposerAttachmentPillArgs {
+ serverId: string;
attachment: ComposerAttachment;
index: number;
disabled: boolean;
@@ -453,6 +457,7 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
if (attachment.kind === "plugin_resource") {
return (
renderAttachmentTray({
+ serverId,
selectedAttachments,
pendingFiles,
isComposerLocked,
@@ -2314,6 +2320,7 @@ function ComposerContentImpl({
handleOpenAttachment,
handleRemoveAttachment,
isComposerLocked,
+ serverId,
selectedAttachments,
pendingFiles,
t,
diff --git a/packages/app/src/contexts/session-workspace-upserts.test.ts b/packages/app/src/contexts/session-workspace-upserts.test.ts
index c7ef8e281b..c0c3bea91f 100644
--- a/packages/app/src/contexts/session-workspace-upserts.test.ts
+++ b/packages/app/src/contexts/session-workspace-upserts.test.ts
@@ -8,6 +8,8 @@ import {
} from "@/contexts/session-workspace-upserts";
const baseWorkspace: WorkspaceDescriptor = {
+ createdAt: null,
+ lastUserMessageAt: null,
id: "/repo/worktree",
projectId: "/repo",
projectDisplayName: "Repo",
diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts
index f91a9540f2..f0210f1402 100644
--- a/packages/app/src/data/acp-provider-catalog.ts
+++ b/packages/app/src/data/acp-provider-catalog.ts
@@ -38,10 +38,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
- version: "0.33.0",
+ version: "0.36.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
- command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"],
+ command: ["npx", "-y", "@augmentcode/auggie@0.36.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -60,10 +60,10 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
- version: "3.0.46",
+ version: "3.0.73",
iconId: "cline",
installLink: "https://cline.bot/cli",
- command: ["npx", "-y", "cline@3.0.46", "--acp"],
+ command: ["npx", "-y", "cline@3.0.73", "--acp"],
},
{
id: "codebuddy-code",
@@ -123,10 +123,10 @@ const CATALOG_DATA = [
id: "deepagents",
title: "DeepAgents",
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
- version: "0.1.20",
+ version: "0.1.36",
iconId: "deepagents",
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
- command: ["npx", "-y", "deepagents-acp@0.1.20"],
+ command: ["npx", "-y", "deepagents-acp@0.1.36"],
},
{
id: "devin",
@@ -141,29 +141,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
- version: "0.2.36",
+ version: "0.5.21",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
- command: ["npx", "-y", "dimcode@0.2.36", "acp"],
+ command: ["npx", "-y", "dimcode@0.5.21", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
- version: "0.4.22",
+ version: "0.5.23",
iconId: "dirac",
installLink: "https://dirac.run",
- command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"],
+ command: ["npx", "-y", "dirac-cli@0.5.23", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
- version: "0.179.0",
+ version: "0.249.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
- command: ["npx", "-y", "droid@0.179.0", "exec", "--output-format", "acp-daemon"],
+ command: ["npx", "-y", "droid@0.249.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -174,19 +174,19 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
- version: "0.9.22",
+ version: "0.10.47",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
- command: ["uvx", "--from", "fast-agent-acp==0.9.22", "fast-agent-acp", "-x"],
+ command: ["uvx", "--from", "fast-agent-acp==0.10.47", "fast-agent-acp", "-x"],
},
{
id: "gemini",
title: "Gemini CLI",
description: "Google's official CLI for Gemini",
- version: "0.52.0",
+ version: "0.65.0",
iconId: "gemini",
installLink: "https://geminicli.com",
- command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"],
+ command: ["npx", "-y", "@google/gemini-cli@0.65.0", "--acp"],
},
{
id: "gjc",
@@ -205,11 +205,11 @@ const CATALOG_DATA = [
id: "glm-acp-agent",
title: "GLM Agent",
description:
- "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
- version: "1.3.0",
+ "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.3, glm-5.3-flash, glm-5-turbo, glm-4.7). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
+ version: "1.15.0",
iconId: "glm-acp-agent",
installLink: "https://github.com/stefandevo/glm-acp-agent",
- command: ["npx", "-y", "glm-acp-agent@1.3.0"],
+ command: ["npx", "-y", "glm-acp-agent@1.15.0"],
},
{
id: "goose",
@@ -289,20 +289,20 @@ const CATALOG_DATA = [
id: "minimax-code",
title: "MiniMax Code",
description: "MiniMax's coding agent for the terminal",
- version: "0.1.2",
+ version: "0.6.13",
iconId: "minimax-code",
installLink: "https://agent.minimax.io",
- command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"],
+ command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
},
{
id: "minion-code",
title: "Minion Code",
description:
"An enhanced AI code assistant built on the Minion framework with rich development tools",
- version: "0.1.44",
+ version: "0.1.45",
iconId: "minion-code",
installLink: "https://github.com/femto/minion-code",
- command: ["uvx", "--from", "minion-code==0.1.44", "minion-code", "acp"],
+ command: ["uvx", "--from", "minion-code==0.1.45", "minion-code", "acp"],
},
{
id: "mistral-vibe",
@@ -317,10 +317,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
- version: "1.1.29",
+ version: "1.1.59",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
- command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"],
+ command: ["npx", "-y", "@compass-ai/nova@1.1.59", "acp"],
},
{
id: "poolside",
@@ -335,19 +335,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
- version: "1.1.4",
+ version: "1.1.78",
iconId: "qoder",
installLink: "https://qoder.com",
- command: ["npx", "-y", "@qoder-ai/qodercli@1.1.4", "--acp"],
+ command: ["npx", "-y", "@qoder-ai/qodercli@1.1.78", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
- version: "0.20.1",
+ version: "0.26.0",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
- command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"],
+ command: ["npx", "-y", "@qwen-code/qwen-code@0.26.0", "--acp", "--experimental-skills"],
},
{
id: "sigit",
diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts
index 3bf9e99f9b..6599c28467 100644
--- a/packages/app/src/data/push-router.test.ts
+++ b/packages/app/src/data/push-router.test.ts
@@ -51,7 +51,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
emit: (message: Extract) => void;
subscribeCheckoutDiffCalls: Array<{
cwd: string;
- compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
+ compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean };
subscriptionId: string;
}>;
unsubscribeCheckoutDiffCalls: string[];
@@ -67,7 +67,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
};
const subscribeCheckoutDiffCalls: Array<{
cwd: string;
- compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
+ compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean };
subscriptionId: string;
}> = [];
const unsubscribeCheckoutDiffCalls: string[] = [];
@@ -379,12 +379,12 @@ describe("server data push router", () => {
});
});
- it("subscribes active checkout diff queries and writes matching diff events", () => {
+ it.each(["base", "all"] as const)("routes %s diff snapshots and updates", (mode) => {
const queryClient = new QueryClient();
const fake = createFakeClient();
const serverId = "server-1";
const cwd = "/repo";
- const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
+ const queryKey = checkoutDiffQueryKey(serverId, cwd, mode, "main", true);
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
const observer = new QueryObserver(queryClient, {
queryKey,
@@ -397,17 +397,18 @@ describe("server data push router", () => {
serverId,
subscriptionId,
cwd,
- compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
+ compare: { mode, baseRef: "main", ignoreWhitespace: true },
}),
});
const unsubscribeObserver = observer.subscribe(() => undefined);
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
+ expect(fake.subscribeCheckoutDiffCalls).toHaveLength(1);
const serverSubscriptionId = fake.subscribeCheckoutDiffCalls[0]!.subscriptionId;
expect(fake.subscribeCheckoutDiffCalls).toEqual([
{
cwd,
- compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
+ compare: { mode, baseRef: "main", ignoreWhitespace: true },
subscriptionId: serverSubscriptionId,
},
]);
diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts
index 5ab3069bd0..75ac313169 100644
--- a/packages/app/src/data/push-router.ts
+++ b/packages/app/src/data/push-router.ts
@@ -33,7 +33,7 @@ type CheckoutDiffCachePayload = Omit
-
-
-
- {modified ? (
-
- ) : null}
-
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create((theme) => ({
- chrome: {
- flexShrink: 0,
- },
- row: {
- height: "100%",
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[3],
- paddingHorizontal: theme.spacing[3],
- },
- spacer: { flex: 1 },
- modifiedDot: {
- width: 6,
- height: 6,
- borderRadius: theme.borderRadius.full,
- backgroundColor: theme.colors.foregroundExtraMuted,
- },
-}));
diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx
index 68273491d7..5f70030819 100644
--- a/packages/app/src/file-pane/pane.tsx
+++ b/packages/app/src/file-pane/pane.tsx
@@ -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";
@@ -15,7 +16,6 @@ import { useSessionStore } from "@/stores/session-store";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { formatFileSize } from "@/utils/format-file-size";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
-import { FilePanelBar } from "./bar";
/**
* The file tab: stats the file, picks its panel and mounts it. The panel reads, saves and shows
@@ -70,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;
@@ -110,13 +106,12 @@ export function FilePane({
return (
-
@@ -133,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(() => ({
- setModified: (modified) => onModifiedChange(modified),
- }));
+ const { state, setCurrentTabState } = usePaneContext();
+ const tab = useMemo(
+ () => ({
+ setModified: onModifiedChange,
+ state,
+ setState: setCurrentTabState,
+ }),
+ [onModifiedChange, state, setCurrentTabState],
+ );
useEffect(() => () => onModifiedChange(false), [onModifiedChange]);
- return ;
+ return ;
}
const styles = StyleSheet.create((theme) => ({
diff --git a/packages/app/src/file-panels/builtin.tsx b/packages/app/src/file-panels/builtin.tsx
index 8a9466b45e..5407719ab4 100644
--- a/packages/app/src/file-panels/builtin.tsx
+++ b/packages/app/src/file-panels/builtin.tsx
@@ -1,4 +1,4 @@
-import { useFileText, 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,40 @@ 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 { SourcePanel } from "./source/panel";
+import { TextFilePanel, type TextFilePreviewProps } from "./source/panel";
import type { RenderingFilePanel, SourceFilePanel } from "./catalog";
-/** Shows Loading until the rendered Markdown has laid out once; later re-reads keep the content. */
-function MarkdownPanel({ file, target }: BuiltinFilePanelProps) {
- const text = useFileText(file);
- const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null);
+/** Source: the text file panel with no Preview, for text files no other built-in panel claims. */
+function SourcePanel(props: PluginFilePanelProps) {
+ return ;
+}
+
+/** Markdown: the text file panel with the rendered document as its Preview. */
+function MarkdownPanel(props: PluginFilePanelProps) {
+ return ;
+}
+
+/** HTML: the text file panel with the sandboxed page as its Preview. */
+function HtmlPanel(props: PluginFilePanelProps) {
+ return ;
+}
+
+/** Shows Loading until the rendered Markdown has laid out once; later edits keep the content. */
+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), []);
- if (text.status === "loading") return ;
- if (text.status === "error") return ;
return (
@@ -37,18 +49,16 @@ function MarkdownPanel({ file, target }: BuiltinFilePanelProps) {
);
}
-function HtmlPanel({ file }: BuiltinFilePanelProps) {
- const text = useFileText(file);
- if (text.status === "loading") return ;
- if (text.status === "error") return ;
+/** The page owns its scrolling, so there is no ScrollView around it. */
+function HtmlPreview({ text }: TextFilePreviewProps) {
return (
-
+
);
}
-function ImagePanel({ file }: BuiltinFilePanelProps) {
+function ImagePanel({ file }: PluginFilePanelProps) {
const url = useFileUrl(file);
if (url.status === "loading") return ;
if (url.status === "error") return ;
@@ -73,7 +83,7 @@ const components = {
export function BuiltinFilePanelView({
panel,
...props
-}: BuiltinFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) {
+}: PluginFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) {
const Component = components[panel.id];
return ;
}
diff --git a/packages/app/src/file-panels/catalog.test.ts b/packages/app/src/file-panels/catalog.test.ts
index 89a7e14cc7..31635e4a6d 100644
--- a/packages/app/src/file-panels/catalog.test.ts
+++ b/packages/app/src/file-panels/catalog.test.ts
@@ -25,8 +25,9 @@ function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[]
}
describe("built-in file panels", () => {
- it("opens each rendered extension with its panel and Source after it", () => {
- expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown", "builtin:source"]);
+ it("opens each rendered extension with its panel alone, never with Source beside it", () => {
+ expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown"]);
+ expect(candidateIds(textFile("plan.html"))).toEqual(["builtin:html"]);
expect(panelId(textFile("notes.markdown"))).toBe("builtin:markdown");
expect(panelId(textFile("plan.html"))).toBe("builtin:html");
expect(panelId(textFile("docs/PLAN.HTML"))).toBe("builtin:html");
@@ -39,16 +40,29 @@ describe("built-in file panels", () => {
}
});
- it("puts plugin panels after the rendering built-ins and before Source", () => {
+ it("puts plugin panels after the built-in that claims the file, and before Source", () => {
expect(candidateIds(textFile("README.md"), filePanelCatalog([richMarkdown]))).toEqual([
"builtin:markdown",
richMarkdown.id,
- "builtin:source",
]);
const onlyPlugin = filePanelCatalog([{ ...richMarkdown, files: { extensions: ["txt"] } }]);
+ expect(candidateIds(textFile("notes.txt"), onlyPlugin)).toEqual([
+ richMarkdown.id,
+ "builtin:source",
+ ]);
expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id);
});
+ it("ignores a remembered Source for a file another built-in claims", () => {
+ const resolution = resolveFilePanel({
+ file: textFile("README.md"),
+ candidates: builtins,
+ associations: { "ext:md": "builtin:source" },
+ });
+ expect(resolution.panel?.id).toBe("builtin:markdown");
+ expect(resolution.associatedPanelId).toBeNull();
+ });
+
it("has no Source for files that are not text", () => {
expect(candidateIds({ path: "archive.zip", kind: "binary" })).toEqual([]);
});
diff --git a/packages/app/src/file-panels/catalog.ts b/packages/app/src/file-panels/catalog.ts
index 6ff5cd8d5a..f77162645e 100644
--- a/packages/app/src/file-panels/catalog.ts
+++ b/packages/app/src/file-panels/catalog.ts
@@ -21,7 +21,10 @@ const renderingPanels = [
},
] as const satisfies readonly FilePanelCandidate[];
-/** Paseo's editor. Every text file has it, so a text file always opens with some panel. */
+/**
+ * Paseo's editor, the floor for text files: every text file no other built-in claims has it, so a
+ * text file always opens with some panel. A built-in that claims a file owns its source view.
+ */
export const sourceFilePanel = {
id: "builtin:source",
builtin: true,
@@ -34,7 +37,7 @@ export type SourceFilePanel = typeof sourceFilePanel;
/**
* Every panel a host offers, in resolver order: the built-ins that render a file, then plugin
- * panels, then Source. Source is last so it is the default only when nothing else claims the file.
+ * panels, then Source. Source is last so a plugin panel is the default over it.
*/
export function filePanelCatalog(
pluginPanels: readonly Plugin[],
diff --git a/packages/app/src/file-panels/choice/hook.ts b/packages/app/src/file-panels/choice/hook.ts
index 0e51a69c4b..2c2fafe388 100644
--- a/packages/app/src/file-panels/choice/hook.ts
+++ b/packages/app/src/file-panels/choice/hook.ts
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
import { usePaneContext } from "@/panels/pane-context";
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
import { useFilePanelAssociations } from "../associations";
-import { sourceFilePanel } from "../catalog";
import { useFilePanels, type FilePanel } from "../panels";
import { resolveFilePanel, type FilePanelFileKind } from "../resolve";
import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model";
@@ -29,9 +28,7 @@ export function useFilePanelChoice(input: {
const { serverId, workspaceId, tabId } = usePaneContext();
const candidates = useFilePanels(input.serverId);
const { associations, remember } = useFilePanelAssociations();
- const [choice] = useState(
- () => new FilePanelChoiceModel({ remember, linePanelId: sourceFilePanel.id }),
- );
+ const [choice] = useState(() => new FilePanelChoiceModel({ remember }));
// From the subscribed snapshot: React Compiler caches reads off the stable model object.
const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey);
const resolution = useMemo(
diff --git a/packages/app/src/file-panels/choice/model.test.ts b/packages/app/src/file-panels/choice/model.test.ts
index e9e5e83042..73e98e91a1 100644
--- a/packages/app/src/file-panels/choice/model.test.ts
+++ b/packages/app/src/file-panels/choice/model.test.ts
@@ -8,6 +8,11 @@ const rich: FilePanelCandidate = {
builtin: false,
files: { extensions: ["md", "markdown"] },
};
+const richTypeScript: FilePanelCandidate = {
+ id: "plugin:ts/outline",
+ builtin: false,
+ files: { extensions: ["ts"] },
+};
const markdown = "builtin:markdown";
const source = sourceFilePanel.id;
@@ -22,7 +27,6 @@ function openTab(
if (panelId === null) delete associations[selectorKey];
else associations[selectorKey] = panelId;
},
- linePanelId: source,
});
let targetKey = `/repo:${options.path ?? "notes.md"}`;
let presentable = options.presentable ?? true;
@@ -70,11 +74,7 @@ describe("FilePanelChoiceModel", () => {
selectedPanelId: markdown,
remember: false,
});
- expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([
- markdown,
- rich.id,
- source,
- ]);
+ expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([markdown, rich.id]);
tab.model.dismissPicker();
expect(tab.picker()).toBeNull();
expect(tab.panel()).toBe(markdown);
@@ -84,17 +84,13 @@ describe("FilePanelChoiceModel", () => {
expect(openTab(associations).picker()).not.toBeNull();
});
- it("never asks between built-in panels, and switches between them", () => {
+ it("opens a file a built-in claims with that built-in alone and nothing to choose", () => {
const tab = openTab({}, [...filePanelCatalog([])]);
expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toBeNull();
- expect(tab.model.getSnapshot().openWith?.candidates.map((candidate) => candidate.id)).toEqual([
- markdown,
- source,
- ]);
+ expect(tab.model.getSnapshot().openWith).toBeNull();
tab.model.openWith(source);
- expect(tab.panel()).toBe(source);
- expect(tab.picker()).toBeNull();
+ expect(tab.panel()).toBe(markdown);
});
it("opens a file only Source claims with Source and nothing to choose", () => {
@@ -133,7 +129,7 @@ describe("FilePanelChoiceModel", () => {
it("does not ask with an association or while the tab is in the background", () => {
expect(openTab({ "ext:md": rich.id }).picker()).toBeNull();
- expect(openTab({ "ext:md": source }).panel()).toBe(source);
+ expect(openTab({ "ext:md": source }).panel()).toBe(markdown);
const background = openTab({}, undefined, { presentable: false });
expect(background.picker()).toBeNull();
@@ -141,21 +137,29 @@ describe("FilePanelChoiceModel", () => {
expect(background.picker()).not.toBeNull();
});
- it("opens Source for a line target without asking, and again on each new one", () => {
- const tab = openTab({}, undefined, { lineNavigation: 1 });
- expect(tab.panel()).toBe(source);
+ it("opens the built-in panel for a line target without asking, and again on each new one", () => {
+ const tab = openTab({ "ext:md": rich.id }, undefined, { lineNavigation: 1 });
+ expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toBeNull();
tab.model.openWith(rich.id);
expect(tab.panel()).toBe(rich.id);
expect(tab.navigateToLine(1)).toBe(rich.id);
- expect(tab.navigateToLine(2)).toBe(source);
+ expect(tab.navigateToLine(2)).toBe(markdown);
});
- it("opens Source for the same line target in a retargeted tab", () => {
+ it("opens Source for a line target in a file only Source and a plugin claim", () => {
+ const tab = openTab({ "ext:ts": richTypeScript.id }, [...filePanelCatalog([richTypeScript])], {
+ path: "index.ts",
+ });
+ expect(tab.panel()).toBe(richTypeScript.id);
+ expect(tab.navigateToLine(1)).toBe(source);
+ });
+
+ it("opens the built-in panel for the same line target in a retargeted tab", () => {
const tab = openTab({}, undefined, { lineNavigation: 1 });
- tab.model.openWith(markdown);
- expect(tab.retarget("other.md")).toBe(source);
+ tab.model.openWith(rich.id);
+ expect(tab.retarget("other.md")).toBe(markdown);
});
it("starts a new open when the tab is retargeted", () => {
diff --git a/packages/app/src/file-panels/choice/model.ts b/packages/app/src/file-panels/choice/model.ts
index 52cc4072ce..c28daeca6e 100644
--- a/packages/app/src/file-panels/choice/model.ts
+++ b/packages/app/src/file-panels/choice/model.ts
@@ -30,15 +30,13 @@ export interface FilePanelChoiceSnapshot {
/** The panel picked for this tab open. Resolution prefers it over the association. */
chosenPanelId: string | null;
picker: FilePanelPickerState | null;
- /** Present only when the file has more than one candidate; drives the bar's panel switcher. */
+ /** Present only when the file has more than one candidate; drives "Open with…". */
openWith: FilePanelOpenWithState | null;
}
export interface FilePanelChoiceDeps {
/** Writes (or clears, with null) the device-wide association for a selector key. */
remember(selectorKey: string, panelId: string | null): void;
- /** The panel a line target opens: Source, the panel that shows lines. */
- linePanelId: string;
}
/** The tab choice that applies to `targetKey`; a choice never outlives its open. */
@@ -61,7 +59,8 @@ const EMPTY: FilePanelChoiceSnapshot = {
*
* A tab open is one target in one tab: the picker is offered at most once per open, the first
* time the tab is on screen with a plugin panel among several candidates and nothing remembered.
- * Built-in panels alone never ask. A line target opens the line panel the way "Open with…" does.
+ * Built-in panels alone never ask. A line target opens the file's built-in panel, which shows
+ * lines, the way "Open with…" does.
* Retargeting the tab starts a new open. Choices made here apply to this tab; only "Don't ask me
* again" and "Always use" write the association every later open reads.
*/
@@ -70,6 +69,8 @@ export class FilePanelChoiceModel {
private snapshot: FilePanelChoiceSnapshot = EMPTY;
private asked = false;
private openedLineNavigation: number | null = null;
+ /** A line target arrived before the file resolved; it opens the built-in once it does. */
+ private lineTargetPending = false;
constructor(private readonly deps: FilePanelChoiceDeps) {}
@@ -85,11 +86,17 @@ export class FilePanelChoiceModel {
if (source.targetKey !== current.targetKey) {
this.asked = false;
this.openedLineNavigation = null;
+ this.lineTargetPending = false;
current = { ...EMPTY, targetKey: source.targetKey };
}
if (source.lineNavigation !== null && source.lineNavigation !== this.openedLineNavigation) {
this.openedLineNavigation = source.lineNavigation;
- current = { ...current, chosenPanelId: this.deps.linePanelId, picker: null };
+ this.lineTargetPending = true;
+ }
+ if (this.lineTargetPending && source.resolution) {
+ this.lineTargetPending = false;
+ const builtin = source.resolution.candidates.find((candidate) => candidate.builtin);
+ if (builtin) current = { ...current, chosenPanelId: builtin.id, picker: null };
}
const openWith = stableOpenWith(current.openWith, openWithState(source.resolution));
let picker = openWith ? current.picker : null;
diff --git a/packages/app/src/file-panels/choice/open-with.tsx b/packages/app/src/file-panels/choice/open-with.tsx
index b793b4f445..33a10aa254 100644
--- a/packages/app/src/file-panels/choice/open-with.tsx
+++ b/packages/app/src/file-panels/choice/open-with.tsx
@@ -1,17 +1,12 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
-import { Ellipsis } from "lucide-react-native";
import {
MenuItem,
MenuSeparator,
MenuSubTrigger,
type MenuPageDefinition,
} from "@/components/ui/menu";
-import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
-import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
-import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
-import { useIsCompactFormFactor } from "@/constants/layout";
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
import { PluginIconView } from "@/plugins/icons";
import type { Theme } from "@/styles/theme";
@@ -22,7 +17,6 @@ import { filePanelAlwaysUseLabel, filePanelIcon, filePanelOptionLabel } from "./
const OPEN_WITH_PAGE_ID = "filePanelOpenWith";
const MENU_ICON_SIZE = 16;
-const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedPanelIcon = withUnistyles(PluginIconView);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -43,41 +37,6 @@ export function useFilePanelOpenWithMenu(
return useOpenWithMenu(useTabFilePanelChoice(identity), testIDBase);
}
-/**
- * The file bar's overflow: remembering the panel the bar's switcher shows. Renders nothing unless
- * the file has more than one panel.
- */
-export function FilePanelBarMenu({ choice }: { choice: FilePanelChoice }) {
- const { t } = useTranslation();
- const compact = useIsCompactFormFactor();
- const { openWith } = useFilePanelChoiceSnapshot(choice);
- if (!openWith) return null;
- const label = t("panels.file.filePanels.actions");
- return (
-
-
-
-
-
-
-
-
- );
-}
-
function useOpenWithMenu(
choice: FilePanelChoice | null,
testIDBase: string,
diff --git a/packages/app/src/file-panels/choice/presentation.ts b/packages/app/src/file-panels/choice/presentation.ts
index 9c0de9170b..34ecc7265b 100644
--- a/packages/app/src/file-panels/choice/presentation.ts
+++ b/packages/app/src/file-panels/choice/presentation.ts
@@ -14,11 +14,11 @@ export function filePanelIcon(panel: FilePanel): PluginIcon {
return panel.builtin ? BUILTIN_PRESENTATION[panel.id].icon : panel.contribution.icon;
}
-/** "Markdown", "Rich Markdown": the panel switcher's label. */
+/** "Markdown", "Rich Markdown": the panel's title. */
export function filePanelTitle(t: TFunction, panel: FilePanel): string {
return panel.builtin
? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`)
- : panel.contribution.title;
+ : panel.plugin.i18n.resolve(panel.contribution.title, panel.plugin.locale);
}
/** "Markdown — Paseo", "Rich Markdown — markdown-panel". */
diff --git a/packages/app/src/file-panels/choice/switcher-layout.test.ts b/packages/app/src/file-panels/choice/switcher-layout.test.ts
deleted file mode 100644
index 196f03bf17..0000000000
--- a/packages/app/src/file-panels/choice/switcher-layout.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { FilePanelOpenWithState } from "./model";
-import { filePanelSwitcherState } from "./switcher-layout";
-
-function openWith(count: number): FilePanelOpenWithState<{ id: string }> {
- const candidates = Array.from({ length: count }, (_, index) => ({ id: `panel-${index}` }));
- return { selectorKey: "ext:md", candidates, activePanelId: "panel-1", remembered: false };
-}
-
-describe("filePanelSwitcherState", () => {
- it("shows nothing when the file has one panel or none", () => {
- expect(filePanelSwitcherState(null, { compact: false })).toBeNull();
- });
-
- it("sets up to three panels side by side and more behind a menu", () => {
- const wide = { compact: false };
- expect(filePanelSwitcherState(openWith(2), wide)?.layout).toBe("segmented");
- expect(filePanelSwitcherState(openWith(3), wide)?.layout).toBe("segmented");
- expect(filePanelSwitcherState(openWith(4), wide)).toMatchObject({
- layout: "menu",
- activePanelId: "panel-1",
- candidates: openWith(4).candidates,
- });
- });
-
- it("sets two panels side by side on a compact screen and more behind a menu", () => {
- const compact = { compact: true };
- expect(filePanelSwitcherState(openWith(2), compact)?.layout).toBe("segmented");
- expect(filePanelSwitcherState(openWith(3), compact)?.layout).toBe("menu");
- });
-});
diff --git a/packages/app/src/file-panels/choice/switcher-layout.ts b/packages/app/src/file-panels/choice/switcher-layout.ts
deleted file mode 100644
index 16afae0fe9..0000000000
--- a/packages/app/src/file-panels/choice/switcher-layout.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { FilePanelOpenWithState } from "./model";
-
-/** Up to this many panels sit side by side in the bar; more go behind a menu. */
-const SEGMENTED_PANEL_LIMIT = { wide: 3, compact: 2 };
-
-export interface FilePanelSwitcherState {
- layout: "segmented" | "menu";
- candidates: readonly Candidate[];
- activePanelId: string;
-}
-
-/** The bar's panel switcher for a tab's choice; null when the file has one panel or none. */
-export function filePanelSwitcherState(
- openWith: FilePanelOpenWithState | null,
- { compact }: { compact: boolean },
-): FilePanelSwitcherState | null {
- if (!openWith) return null;
- const limit = compact ? SEGMENTED_PANEL_LIMIT.compact : SEGMENTED_PANEL_LIMIT.wide;
- return {
- layout: openWith.candidates.length > limit ? "menu" : "segmented",
- candidates: openWith.candidates,
- activePanelId: openWith.activePanelId,
- };
-}
diff --git a/packages/app/src/file-panels/choice/switcher.tsx b/packages/app/src/file-panels/choice/switcher.tsx
deleted file mode 100644
index 75aef62727..0000000000
--- a/packages/app/src/file-panels/choice/switcher.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import { useCallback, useMemo } from "react";
-import { Text } from "react-native";
-import { useTranslation } from "react-i18next";
-import { withUnistyles } from "react-native-unistyles";
-import { ChevronDown } from "lucide-react-native";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
-import { MenuItem } from "@/components/ui/menu";
-import { SegmentedControl } from "@/components/ui/segmented-control";
-import {
- ToolbarLabelTriggerIcon,
- isToolbarLabelTriggerHighlighted,
- toolbarLabelTriggerStyle,
- toolbarLabelTriggerTextStyle,
-} from "@/components/ui/toolbar-label-trigger";
-import { useIsCompactFormFactor } from "@/constants/layout";
-import { PluginIconView } from "@/plugins/icons";
-import type { Theme } from "@/styles/theme";
-import type { FilePanel } from "../panels";
-import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook";
-import { filePanelIcon, filePanelTitle } from "./presentation";
-import { filePanelSwitcherState, type FilePanelSwitcherState } from "./switcher-layout";
-
-const ThemedChevronDown = withUnistyles(ChevronDown);
-const ThemedPanelIcon = withUnistyles(PluginIconView);
-const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
-
-/** The file bar's panel switcher. Renders nothing unless the file has more than one panel. */
-export function FilePanelSwitcher({ choice }: { choice: FilePanelChoice }) {
- const { openWith } = useFilePanelChoiceSnapshot(choice);
- const compact = useIsCompactFormFactor();
- const switcher = filePanelSwitcherState(openWith, { compact });
- const select = useCallback((panelId: string) => choice.openWith(panelId), [choice]);
- if (!switcher) return null;
- return switcher.layout === "segmented" ? (
-
- ) : (
-
- );
-}
-
-interface SwitcherProps {
- switcher: FilePanelSwitcherState;
- onSelect(panelId: string): void;
-}
-
-function SegmentedSwitcher({ switcher, onSelect }: SwitcherProps) {
- const { t } = useTranslation();
- const options = useMemo(
- () =>
- switcher.candidates.map((panel) => ({
- value: panel.id,
- label: filePanelTitle(t, panel),
- testID: `file-panel-switcher-${panel.id}`,
- })),
- [switcher.candidates, t],
- );
- return (
-
- );
-}
-
-function MenuSwitcher({ switcher, onSelect }: SwitcherProps) {
- const { t } = useTranslation();
- const active = switcher.candidates.find((panel) => panel.id === switcher.activePanelId);
- const activeTitle = active ? filePanelTitle(t, active) : "";
- return (
-
-
- {(state) => (
- <>
-
- {activeTitle}
-
-
-
-
- >
- )}
-
-
- {switcher.candidates.map((panel) => (
-
- ))}
-
-
- );
-}
-
-function SwitcherMenuItem({
- panel,
- selected,
- onSelect,
-}: {
- panel: FilePanel;
- selected: boolean;
- onSelect(panelId: string): void;
-}) {
- const { t } = useTranslation();
- const handleSelect = useCallback(() => onSelect(panel.id), [onSelect, panel.id]);
- const leading = useMemo(
- () => ,
- [panel],
- );
- return (
-
- );
-}
diff --git a/packages/app/src/file-panels/index.ts b/packages/app/src/file-panels/index.ts
index 5219632fcb..2e2b156ebb 100644
--- a/packages/app/src/file-panels/index.ts
+++ b/packages/app/src/file-panels/index.ts
@@ -6,12 +6,7 @@ export {
type FilePanelChoice,
} from "./choice/hook";
export { FilePanelPicker } from "./choice/picker";
-export { FilePanelSwitcher } from "./choice/switcher";
-export {
- FilePanelBarMenu,
- useFilePanelOpenWithMenu,
- type FilePanelOpenWithMenu,
-} from "./choice/open-with";
+export { useFilePanelOpenWithMenu, type FilePanelOpenWithMenu } from "./choice/open-with";
export { DocumentFrame } from "./document-frame";
export { filePanelIcon, filePanelOptionLabel, filePanelSelectorLabel } from "./choice/presentation";
export { useFilePanelSettings, type FilePanelSettings } from "./settings/hook";
diff --git a/packages/app/src/file-panels/panels.ts b/packages/app/src/file-panels/panels.ts
index 6ed609e07c..b3f33e040a 100644
--- a/packages/app/src/file-panels/panels.ts
+++ b/packages/app/src/file-panels/panels.ts
@@ -1,12 +1,6 @@
import { useMemo } from "react";
-import type {
- PluginFile,
- PluginFileLocation,
- PluginFilePanelContribution,
- PluginFileTab,
-} from "@getpaseo/plugin/client";
import { useInstalledPlugins } from "@/plugins/registry";
-import type { InstalledPlugin } from "@/plugins/types";
+import type { PluginFilePanelContribution, InstalledPlugin } from "@/plugins/types";
import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog";
export interface PluginFilePanel {
@@ -18,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(
diff --git a/packages/app/src/file-panels/plugin-host.tsx b/packages/app/src/file-panels/plugin-host.tsx
index 06b2260a8b..4cc96cdc3d 100644
--- a/packages/app/src/file-panels/plugin-host.tsx
+++ b/packages/app/src/file-panels/plugin-host.tsx
@@ -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 & { 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 (
-
+
);
}
-const ThemedHost = withUnistyles(PluginHost);
-const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
-export function PluginFilePanelHost(props: Props) {
- return ;
-}
diff --git a/packages/app/src/file-panels/resolve.test.ts b/packages/app/src/file-panels/resolve.test.ts
index b0712ff7c1..3c7372b441 100644
--- a/packages/app/src/file-panels/resolve.test.ts
+++ b/packages/app/src/file-panels/resolve.test.ts
@@ -70,6 +70,17 @@ describe("resolveFilePanel", () => {
});
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [every] }).panel).toBeNull();
});
+ it("offers a built-in panel claiming every file only when no other built-in claims the file", () => {
+ const every: FilePanelCandidate = { ...markdown, id: "builtin:source", files: "all" };
+ expect(resolve({ candidates: [markdown, alternative, every] }).candidates).toEqual([
+ markdown,
+ alternative,
+ ]);
+ expect(resolve({ candidates: [alternative, every] }).candidates).toEqual([alternative, every]);
+ expect(
+ resolve({ file: { ...file, kind: "binary" }, candidates: [markdown, every] }).candidates,
+ ).toEqual([]);
+ });
it("passes over a built-in panel for another kind of file, even with an association", () => {
expect(
resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel,
diff --git a/packages/app/src/file-panels/resolve.ts b/packages/app/src/file-panels/resolve.ts
index 6b0f2ffbd1..1c9fefb78a 100644
--- a/packages/app/src/file-panels/resolve.ts
+++ b/packages/app/src/file-panels/resolve.ts
@@ -99,6 +99,14 @@ export function claimsFilePanelSelectorKey(
: matchesName("", value, selector);
}
+/**
+ * A built-in panel claiming every file of its kind (Source) is the floor: a candidate only for
+ * files no other built-in panel claims, never an alternative beside one.
+ */
+function isBuiltinFloor(candidate: FilePanelCandidate): boolean {
+ return candidate.builtin && candidate.files === "all";
+}
+
/** Paseo asks which panel opens a file only when a plugin panel is one of several candidates. */
export function asksForFilePanel(candidates: readonly FilePanelCandidate[]): boolean {
return candidates.length > 1 && candidates.some((candidate) => !candidate.builtin);
@@ -112,11 +120,17 @@ export function resolveFilePanel(input: {
choice?: string | null;
}): FilePanelResolution {
const { file, associations } = input;
- const candidates = input.candidates.filter(
+ const matching = input.candidates.filter(
(candidate) =>
(candidate.fileKind === undefined || candidate.fileKind === file.kind) &&
matchesFiles(file, candidate.files),
);
+ const claimedByBuiltin = matching.some(
+ (candidate) => candidate.builtin && candidate.files !== "all",
+ );
+ const candidates = claimedByBuiltin
+ ? matching.filter((candidate) => !isBuiltinFloor(candidate))
+ : matching;
const filename = filePanelBasename(file.path);
const matchedFilename = candidates.some((candidate) => claimsFilename(candidate.files, filename));
const selectorKey = matchedFilename
diff --git a/packages/app/src/file-panels/settings/rows.test.ts b/packages/app/src/file-panels/settings/rows.test.ts
index 170ffba04e..4cfc89ec75 100644
--- a/packages/app/src/file-panels/settings/rows.test.ts
+++ b/packages/app/src/file-panels/settings/rows.test.ts
@@ -39,12 +39,12 @@ describe("buildFilePanelSettingsRows", () => {
expect(rows(catalog())).toEqual([]);
});
- it("lists a key a plugin panel claims, built-in first and Source last", () => {
+ it("lists a key a plugin panel claims with the built-in first and no Source", () => {
const plugin = richMarkdown("host-a");
expect(rows(catalog(plugin))).toEqual([
{
selectorKey: "ext:md",
- candidates: [builtinMarkdown, plugin, source],
+ candidates: [builtinMarkdown, plugin],
associatedPanelId: null,
},
]);
@@ -77,19 +77,25 @@ describe("buildFilePanelSettingsRows", () => {
it("offers a panel installed on several hosts once, as the first host has it", () => {
const first = richMarkdown("host-a");
const [row] = rows(catalog(first, richMarkdown("host-b")));
- expect(row?.candidates).toEqual([builtinMarkdown, first, source]);
+ expect(row?.candidates).toEqual([builtinMarkdown, first]);
});
it("keeps a remembered key that only built-in panels claim", () => {
expect(rows(catalog(), { "ext:markdown": source.id })).toEqual([
{
selectorKey: "ext:markdown",
- candidates: [builtinMarkdown, source],
+ candidates: [builtinMarkdown],
associatedPanelId: source.id,
},
]);
});
+ it("offers only Source on a text key no other panel claims", () => {
+ expect(rows(catalog(), { "ext:ts": source.id })).toEqual([
+ { selectorKey: "ext:ts", candidates: [source], associatedPanelId: source.id },
+ ]);
+ });
+
it("keeps a remembered key whose panel is on no connected host", () => {
expect(rows(catalog(), { "ext:pdf": "plugin:pdf-panel/pdf" })).toEqual([
{ selectorKey: "ext:pdf", candidates: [source], associatedPanelId: "plugin:pdf-panel/pdf" },
@@ -104,7 +110,7 @@ describe("buildFilePanelSettingsRows", () => {
};
const keys = rows(catalog(named)).map((row) => row.selectorKey);
expect(keys).toEqual(["name:README.md"]);
- expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named, source]);
+ expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named]);
});
it("orders extensions before filenames, each alphabetically", () => {
diff --git a/packages/app/src/file-panels/settings/rows.ts b/packages/app/src/file-panels/settings/rows.ts
index b2f8bc1971..339e2b6562 100644
--- a/packages/app/src/file-panels/settings/rows.ts
+++ b/packages/app/src/file-panels/settings/rows.ts
@@ -40,10 +40,9 @@ export function buildFilePanelSettingsRows
}
/**
- * The panels offered under a key, by the resolver's matching rule. A panel claiming every file of
- * a kind (Source) is offered unless a built-in panel claiming the key reads another kind: without a
- * file, that is the only knowledge of the key's kind. A remembered panel that does not fit the file
- * when it opens is ignored there.
+ * The panels offered under a key, by the resolver's matching rule. The built-in floor (Source) is
+ * offered only when no built-in panel claims the key. A remembered panel that does not fit the
+ * file when it opens is ignored there.
*/
function offeredCandidates(
candidates: readonly Candidate[],
@@ -55,7 +54,7 @@ function offeredCandidates(
);
return candidates.filter((candidate) =>
candidate.files === "all"
- ? !claiming.some((claimant) => claimant.builtin && claimant.fileKind !== candidate.fileKind)
+ ? !claiming.some((claimant) => claimant.builtin)
: claiming.includes(candidate),
);
}
diff --git a/packages/app/src/file-panels/source/panel.tsx b/packages/app/src/file-panels/source/panel.tsx
index eb6179e08e..23ea42f875 100644
--- a/packages/app/src/file-panels/source/panel.tsx
+++ b/packages/app/src/file-panels/source/panel.tsx
@@ -1,5 +1,17 @@
-import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
-import { useFileText } from "@getpaseo/plugin/client";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ useSyncExternalStore,
+ type ComponentProps,
+ type ComponentType,
+} from "react";
+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";
@@ -10,13 +22,12 @@ 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 { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
+import type { FileConflictAlertState } from "./conflict-alert";
import { FileEditorModel, getFileConflictCallout } from "./editor/model";
import { FileEditorView } from "./editor/view";
import { selectSourcePresentation } from "./readonly/presentation";
import { FileSourceView } from "./readonly/view";
-import { SourceStatusRow } from "./status";
+import { TextFileToolbar, type TextFileView } from "./toolbar";
// CodeMirror and native source rendering consume appearance as props. Subscribe at the
// rendering leaf so theme updates reconfigure it without replacing the editor/model.
@@ -40,19 +51,52 @@ const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance);
/** The daemon refuses to write larger files. */
const MAX_EDITABLE_BYTES = 1024 * 1024;
-/**
- * Paseo's editor for text files. Edits files up to 1 MiB on web; shows larger files and every file
- * on native read-only, and refuses files above the display budget before reading them.
- */
-export function SourcePanel(props: BuiltinFilePanelProps) {
- const size = props.file.size;
- const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" });
- if (presentation === "unsupported") return ;
- if (isWeb && size <= MAX_EDITABLE_BYTES) return ;
- return ;
+/** A rendered view of the file's text: the panel's Preview, shown instead of its source. */
+export interface TextFilePreviewProps {
+ text: string;
+ params: PluginFilePanelProps["params"];
+ workspaceId: string;
+ host: PluginFilePanelProps["host"];
}
-function TooLargeSource({ size }: { size: number }) {
+type TextFilePanelProps = PluginFilePanelProps & {
+ /** The panel's Preview; with one, the toolbar switches between Preview and Source. */
+ preview?: ComponentType;
+};
+
+/**
+ * What the Markdown, HTML and Source panels share: a toolbar over the file's Source view and, when
+ * the panel passes one, its Preview. Both views show the one read of the file and, while editing, the one editor buffer, so switching
+ * between them neither re-reads nor loses edits. Edits files up to 1 MiB on web; shows larger files
+ * and every file on native read-only, and refuses files above the display budget before reading.
+ */
+export function TextFilePanel(props: TextFilePanelProps) {
+ const size = props.file.size;
+ const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" });
+ if (presentation === "unsupported") return ;
+ if (isWeb && size <= MAX_EDITABLE_BYTES) return ;
+ return ;
+}
+
+/**
+ * The view a panel with a preview shows. It opens on Preview, and on Source for a line target;
+ * each later line target switches to Source again.
+ */
+function useTextFileView(
+ params: PluginFilePanelParams,
+ revision: number,
+): [TextFileView, (view: TextFileView) => void] {
+ const lineTarget = params.lineStart ? revision : null;
+ const [view, setView] = useState(lineTarget === null ? "preview" : "source");
+ const [shownLineTarget, setShownLineTarget] = useState(lineTarget);
+ if (lineTarget !== null && lineTarget !== shownLineTarget) {
+ setShownLineTarget(lineTarget);
+ setView("source");
+ }
+ return [view, setView];
+}
+
+function TooLargeTextFile({ size }: { size: number }) {
const { t } = useTranslation();
return (
@@ -62,17 +106,21 @@ function TooLargeSource({ size }: { size: number }) {
);
}
-function ReadonlySource({
+function ReadonlyTextFile({
file,
- location,
- target,
+ params,
+ revision,
+ workspaceId,
+ host,
+ preview: Preview,
presentation,
-}: BuiltinFilePanelProps & { presentation: "highlighted" | "plain" }) {
+}: TextFilePanelProps & { presentation: "highlighted" | "plain" }) {
const text = useFileText(file);
const { t } = useTranslation();
+ 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 ;
if (text.status === "error") {
@@ -82,25 +130,43 @@ function ReadonlySource({
);
}
+ const showPreview = Preview !== undefined && view === "preview";
return (
-
-
+ {showPreview ? (
+
+ ) : (
+
+ )}
);
}
-function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps) {
+function EditableTextFile({
+ file,
+ tab,
+ params,
+ revision,
+ workspaceId,
+ host,
+ preview: Preview,
+}: TextFilePanelProps) {
const { settings } = useAppSettings();
const { t } = useTranslation();
const [model] = useState(() => new FileEditorModel({ file }));
- const [editorReady, setEditorReady] = useState(false);
+ const [view, setView] = useTextFileView(params, revision);
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [vimMode, setVimMode] = useState(settings.vimKeybindings ? "NORMAL" : null);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
@@ -130,10 +196,9 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps)
}, [model, snapshot.modified, t]);
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
const handleRetry = useCallback(() => void model.reload(), [model]);
- const handleReady = useCallback(() => setEditorReady(true), []);
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 ;
@@ -159,29 +224,44 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps)
onOverwrite: handleOverwrite,
};
}
+ const showPreview = Preview !== undefined && view === "preview";
return (
- {conflict ? : null}
-
-
+ {showPreview ? (
+
+ ) : (
+
- {editorReady ? null : }
-
-
+ )}
+
+ );
+}
+
+/** The editor over the panel's buffer. Each mount shows Loading until the editor shows the text. */
+function SourceEditor(props: Omit, "onReady">) {
+ const [ready, setReady] = useState(false);
+ const handleReady = useCallback(() => setReady(true), []);
+ return (
+
+
+ {ready ? null : }
);
}
diff --git a/packages/app/src/file-panels/source/status.tsx b/packages/app/src/file-panels/source/status.tsx
deleted file mode 100644
index a3582f3c5d..0000000000
--- a/packages/app/src/file-panels/source/status.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import { Text, View } from "react-native";
-import { StyleSheet, withUnistyles } from "react-native-unistyles";
-import { useTranslation } from "react-i18next";
-import { LoadingSpinner } from "@/components/ui/loading-spinner";
-import type { Theme } from "@/styles/theme";
-import { formatFileSize } from "@/utils/format-file-size";
-import type { FileEditorStatus } from "./editor/model";
-
-const ThemedSpinner = withUnistyles(LoadingSpinner);
-const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
-
-/** Source's status row: the file's size and lines, the save state, the vim mode and the cursor. */
-export function SourceStatusRow({
- size,
- lineCount,
- editorStatus,
- vimMode,
- cursor,
-}: {
- size: number;
- lineCount: number;
- editorStatus?: FileEditorStatus;
- vimMode?: string | null;
- cursor?: { line: number; column: number };
-}) {
- const { t } = useTranslation();
- return (
-
-
-
- {formatFileSize(size)}
-
-
- {t("panels.file.editor.lines", { count: lineCount })}
-
-
-
- {editorStatus === "dirty" ? (
-
- ) : null}
- {editorStatus === "saving" ? (
- <>
-
- {t("panels.file.editor.saving")}
- >
- ) : null}
- {editorStatus === "error" ? (
- {t("panels.file.editor.saveFailed")}
- ) : null}
- {vimMode ? (
-
- {vimMode}
-
- ) : null}
- {cursor ? (
-
- Ln {cursor.line}, Col {cursor.column}
-
- ) : null}
-
-
- );
-}
-
-const styles = StyleSheet.create((theme) => ({
- row: {
- flexShrink: 0,
- height: 28,
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[3],
- paddingHorizontal: theme.spacing[3],
- borderTopWidth: 1,
- borderTopColor: theme.colors.border,
- },
- metadata: {
- flex: 1,
- minWidth: 0,
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- },
- status: {
- flexShrink: 0,
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- },
- secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
- whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
- error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
- dirtyDot: {
- width: 6,
- height: 6,
- borderRadius: theme.borderRadius.full,
- backgroundColor: theme.colors.foregroundExtraMuted,
- },
- vim: {
- color: theme.colors.foregroundMuted,
- fontFamily: theme.fontFamily.mono,
- fontSize: theme.fontSize.sm,
- },
-}));
diff --git a/packages/app/src/file-panels/source/toolbar.tsx b/packages/app/src/file-panels/source/toolbar.tsx
new file mode 100644
index 0000000000..43cb6f7291
--- /dev/null
+++ b/packages/app/src/file-panels/source/toolbar.tsx
@@ -0,0 +1,163 @@
+import { Text, View } from "react-native";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { useTranslation } from "react-i18next";
+import { LoadingSpinner } from "@/components/ui/loading-spinner";
+import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
+import { SegmentedControl } from "@/components/ui/segmented-control";
+import type { Theme } from "@/styles/theme";
+import { formatFileSize } from "@/utils/format-file-size";
+import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
+import type { FileEditorStatus } from "./editor/model";
+
+const ThemedSpinner = withUnistyles(LoadingSpinner);
+const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
+
+export type TextFileView = "preview" | "source";
+
+/**
+ * The text file panel's toolbar: the file's size and lines, the save state, the vim mode and the
+ * cursor, the Preview|Source control when the panel has a preview, and a conflict under it.
+ */
+export function TextFileToolbar({
+ size,
+ lineCount,
+ view,
+ onViewChange,
+ editorStatus,
+ cursor,
+ vimMode,
+ conflict,
+}: {
+ size: number;
+ lineCount: number;
+ view?: TextFileView;
+ onViewChange?(view: TextFileView): void;
+ editorStatus?: FileEditorStatus;
+ cursor?: { line: number; column: number };
+ vimMode?: string | null;
+ conflict?: FileConflictAlertState | null;
+}) {
+ const { t } = useTranslation();
+ const views = [
+ {
+ value: "preview" as const,
+ label: t("panels.file.editor.preview"),
+ testID: "file-mode-preview",
+ },
+ { value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
+ ];
+ return (
+
+
+
+
+
+ {formatFileSize(size)}
+
+
+ {t("panels.file.editor.lines", { count: lineCount })}
+
+
+
+ {editorStatus === "dirty" ? (
+
+ ) : null}
+ {editorStatus === "saving" ? (
+ <>
+
+ {t("panels.file.editor.saving")}
+ >
+ ) : null}
+ {editorStatus === "error" ? (
+ {t("panels.file.editor.saveFailed")}
+ ) : null}
+ {vimMode ? (
+
+ {vimMode}
+
+ ) : null}
+ {cursor ? (
+
+ Ln {cursor.line}, Col {cursor.column}
+
+ ) : null}
+
+ {view && onViewChange ? (
+
+ ) : null}
+
+
+ {conflict ? : null}
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ chrome: {
+ flexShrink: 0,
+ },
+ row: {
+ height: "100%",
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[3],
+ paddingHorizontal: theme.spacing[3],
+ },
+ metadata: {
+ flex: 1,
+ minWidth: 0,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ },
+ status: {
+ flexShrink: 0,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ },
+ secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
+ whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
+ error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
+ dirtyDot: {
+ width: 6,
+ height: 6,
+ borderRadius: theme.borderRadius.full,
+ backgroundColor: theme.colors.foregroundExtraMuted,
+ },
+ vim: {
+ color: theme.colors.foregroundMuted,
+ fontFamily: theme.fontFamily.mono,
+ fontSize: theme.fontSize.sm,
+ },
+}));
diff --git a/packages/app/src/file-panels/view.tsx b/packages/app/src/file-panels/view.tsx
index 47587effa6..9833a178e1 100644
--- a/packages/app/src/file-panels/view.tsx
+++ b/packages/app/src/file-panels/view.tsx
@@ -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 ;
+type Props = Pick & {
+ 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 (
+
+
+
+ );
return ;
}
+const ThemedHost = withUnistyles(FilePanelHost);
+const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
+export function FilePanelView(props: Props) {
+ return ;
+}
diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts
index ba4ddd2429..026ec9d43a 100644
--- a/packages/app/src/git/checkout-status-cache.test.ts
+++ b/packages/app/src/git/checkout-status-cache.test.ts
@@ -92,12 +92,11 @@ function checkoutStatusUpdate(
};
}
-function selectBaseComparison(isDirtyAtSelection: boolean): void {
+function selectBaseComparison(): void {
selectWorkingDiffComparison({
serverId,
cwd,
comparison: "base",
- isDirty: isDirtyAtSelection,
});
}
@@ -120,14 +119,13 @@ describe("fetchCheckoutStatus", () => {
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
});
- it("expires a manual working-diff comparison when the fetched dirty state flipped", async () => {
- selectBaseComparison(true);
+ it("keeps a manual working-diff comparison when the fetched dirty state flips", async () => {
+ selectBaseComparison();
const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) };
await fetchCheckoutStatus({ client, serverId, cwd });
- expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: false })).toBe("base");
- expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted");
+ expect(resolveWorkingDiffComparison({ serverId, cwd })).toBe("base");
});
});
@@ -286,31 +284,25 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
).toBe("unauthenticated");
});
- it("expires a manual working-diff comparison when the pushed dirty state flipped", () => {
- const queryClient = createQueryClient();
- selectBaseComparison(false);
-
- applyCheckoutStatusUpdateFromEvent({
- queryClient,
- serverId,
- message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
- });
-
- expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted");
- });
-
- it("keeps a manual working-diff comparison while the pushed dirty state still matches", () => {
- const queryClient = createQueryClient();
- selectBaseComparison(true);
-
- applyCheckoutStatusUpdateFromEvent({
- queryClient,
- serverId,
- message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
- });
-
- expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("base");
- });
+ it.each(["base", "uncommitted"] as const)(
+ "keeps %s selected across dirty and clean pushes",
+ (comparison) => {
+ const queryClient = createQueryClient();
+ selectWorkingDiffComparison({ serverId, cwd, comparison });
+ applyCheckoutStatusUpdateFromEvent({
+ queryClient,
+ serverId,
+ message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
+ });
+ expect(resolveWorkingDiffComparison({ serverId, cwd })).toBe(comparison);
+ applyCheckoutStatusUpdateFromEvent({
+ queryClient,
+ serverId,
+ message: checkoutStatusUpdate(checkoutStatus({ isDirty: false })),
+ });
+ expect(resolveWorkingDiffComparison({ serverId, cwd })).toBe(comparison);
+ },
+ );
it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => {
const queryClient = createQueryClient();
diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts
index c06499fef5..ca5cbb3a7a 100644
--- a/packages/app/src/git/checkout-status-cache.ts
+++ b/packages/app/src/git/checkout-status-cache.ts
@@ -9,7 +9,6 @@ import {
} from "@/git/query-keys";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
-import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison";
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
@@ -18,13 +17,8 @@ export interface CheckoutStatusClient {
getCheckoutStatus: (cwd: string) => Promise;
}
-// Checkout status enters the app through exactly two doors: daemon pushes
-// (applyCheckoutStatusUpdateFromEvent) and query fetches (fetchCheckoutStatus). Both run
-// the dirty-state reactions, so they hold regardless of which screens are mounted.
-
export async function fetchCheckoutStatus({
client,
- serverId,
cwd,
}: {
client: CheckoutStatusClient;
@@ -32,7 +26,6 @@ export async function fetchCheckoutStatus({
cwd: string;
}): Promise {
const payload = await client.getCheckoutStatus(cwd);
- expireWorkingDiffComparisons({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
return payload;
}
@@ -78,11 +71,6 @@ export function applyCheckoutStatusUpdateFromEvent({
void queryClient.invalidateQueries({
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
});
- expireWorkingDiffComparisons({
- serverId,
- cwd: payload.cwd,
- isDirty: payload.isGit && payload.isDirty,
- });
if (!prStatus) {
return;
diff --git a/packages/app/src/git/diff-document/workspace-cache.tsx b/packages/app/src/git/diff-document/workspace-cache.tsx
index bd52000af3..5b9921e31d 100644
--- a/packages/app/src/git/diff-document/workspace-cache.tsx
+++ b/packages/app/src/git/diff-document/workspace-cache.tsx
@@ -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(null);
+export function useDiffDocumentWorkspaceCacheBridge() {
+ const cache = useDiffDocumentWorkspaceCache();
+ return useCallback(
+ (children: ReactNode) => (
+ {children}
+ ),
+ [cache],
+ );
+}
+
export function DiffDocumentWorkspaceCacheProvider({ children }: { children: ReactNode }) {
const [cache] = useState(createDiffDocumentWorkspaceCache);
return {children};
diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx
index 22a5c2df05..3dd84f256a 100644
--- a/packages/app/src/git/diff-pane.tsx
+++ b/packages/app/src/git/diff-pane.tsx
@@ -133,7 +133,7 @@ function useDiscardChangesAction({
}: {
serverId: string;
cwd: string;
- diffMode: "uncommitted" | "base";
+ diffMode: "uncommitted" | "base" | "all";
}): ((path: string, oldPath?: string) => void) | undefined {
const { t } = useTranslation();
const toast = useToast();
@@ -281,11 +281,12 @@ export function DiffLayoutToggle({
}
interface DiffModeMenuProps {
- diffMode: "uncommitted" | "base";
+ diffMode: "uncommitted" | "base" | "all";
committedDescription?: string;
testIDPrefix?: string;
onSelectUncommitted: () => void;
onSelectBase: () => void;
+ onSelectAll: () => void;
}
export function DiffModeMenu({
@@ -294,10 +295,12 @@ export function DiffModeMenu({
testIDPrefix = "changes-diff",
onSelectUncommitted,
onSelectBase,
+ onSelectAll,
}: DiffModeMenuProps) {
const { t } = useTranslation();
const uncommittedLabel = t("workspace.git.diff.uncommitted");
const committedLabel = t("workspace.git.diff.committed");
+ const allLabel = t("workspace.git.diff.all");
return (
- {diffMode === "uncommitted" ? uncommittedLabel : committedLabel}
+ {{ all: allLabel, uncommitted: uncommittedLabel, base: committedLabel }[diffMode]}
@@ -321,6 +324,14 @@ export function DiffModeMenu({
}}
+
+ {allLabel}
+
+
void;
+ onSelectAll: () => void;
onSelectUncommitted: () => void;
}
@@ -488,11 +500,12 @@ interface BuildChangesHeaderModelInput {
committedDescription?: string;
compact: boolean;
cwd: string;
- diffMode: "uncommitted" | "base";
+ diffMode: "uncommitted" | "base" | "all";
gitActions: GitActions;
mode: ChangesToolbarMode;
onOpenPullRequest: () => void;
onSelectBase: () => void;
+ onSelectAll: () => void;
onSelectUncommitted: () => void;
pullRequest: PrHint | null;
selectedDiffStat: { additions: number; deletions: number } | null;
@@ -521,6 +534,7 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): {
mode: input.mode,
selectedDiffStat: input.selectedDiffStat,
onSelectBase: input.onSelectBase,
+ onSelectAll: input.onSelectAll,
onSelectUncommitted: input.onSelectUncommitted,
},
};
@@ -737,6 +751,7 @@ function ChangesComparisonToolbar({
committedDescription={model.committedDescription}
onSelectUncommitted={model.onSelectUncommitted}
onSelectBase={model.onSelectBase}
+ onSelectAll={model.onSelectAll}
/>
{model.selectedDiffStat ? (
[number];
function normalizeCheckoutDiffCompare(compare: {
- mode: "uncommitted" | "base";
+ mode: "uncommitted" | "base" | "all";
baseRef?: string;
ignoreWhitespace?: boolean;
-}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
+}): { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean } {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef
- ? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
- : { mode: "base", ignoreWhitespace };
+ ? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace }
+ : { mode: compare.mode, ignoreWhitespace };
}
export function useCheckoutDiffQuery({
@@ -49,6 +50,11 @@ export function useCheckoutDiffQuery({
enabled = true,
queryScope,
}: UseCheckoutDiffQueryOptions) {
+ // COMPAT(checkoutDiffAll): added in v0.12.0, remove after 2027-04-21 once host floor includes All changes.
+ const allSupported = useSessionStore(
+ (state) => state.sessions[serverId]?.serverInfo?.features?.checkoutDiffAll === true,
+ );
+ const requiresUpdate = mode === "all" && !allSupported;
const retainedPanelActive = useRetainedPanelActive();
const queryEnabled = enabled && retainedPanelActive;
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -71,7 +77,7 @@ export function useCheckoutDiffQuery({
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
- const routeEnabled = Boolean(queryEnabled && isConnected && cwd);
+ const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate);
const query = useReplicaQuery({
queryKey,
@@ -91,7 +97,16 @@ export function useCheckoutDiffQuery({
}),
});
- return deriveCheckoutDiffResult(query.data ?? null);
+ return deriveCheckoutDiffResult(
+ requiresUpdate && isConnected
+ ? {
+ requestId: "unsupported",
+ cwd,
+ files: [],
+ error: { code: "UNKNOWN", message: "Update the host to view All changes" },
+ }
+ : (query.data ?? null),
+ );
}
export interface CheckoutDiffResult {
diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts
index daeda1479d..f835d44f53 100644
--- a/packages/app/src/git/use-working-diff.ts
+++ b/packages/app/src/git/use-working-diff.ts
@@ -45,7 +45,6 @@ export function useWorkingDiff({
status?.error?.message ??
(isStatusError && statusError instanceof Error ? statusError.message : null);
const baseRef = gitStatus?.baseRef ?? undefined;
- const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const currentBranchName =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
@@ -53,9 +52,9 @@ export function useWorkingDiff({
serverId,
workspaceId,
cwd,
- isDirty: hasUncommittedChanges,
});
const selectUncommitted = useCallback(() => selectComparison("uncommitted"), [selectComparison]);
+ const selectAll = useCallback(() => selectComparison("all"), [selectComparison]);
const selectBase = useCallback(() => selectComparison("base"), [selectComparison]);
const {
@@ -111,6 +110,7 @@ export function useWorkingDiff({
diffMode,
selectUncommitted,
selectBase,
+ selectAll,
files,
diffPayloadError,
diffTooLarge,
diff --git a/packages/app/src/git/working-diff-comparison/index.ts b/packages/app/src/git/working-diff-comparison/index.ts
index bee2c81b22..84a4b461d2 100644
--- a/packages/app/src/git/working-diff-comparison/index.ts
+++ b/packages/app/src/git/working-diff-comparison/index.ts
@@ -1,7 +1,6 @@
import { useCallback } from "react";
import { create } from "zustand";
import {
- expireWorkingDiffComparisonsInState,
resolveWorkingDiffComparisonFromState,
selectWorkingDiffComparisonInState,
type WorkingDiffCheckoutIdentity,
@@ -13,7 +12,6 @@ interface WorkingDiffComparisonStore extends WorkingDiffComparisonState {
select: (
input: WorkingDiffCheckoutIdentity & {
comparison: WorkingDiffComparison;
- isDirty: boolean;
},
) => void;
}
@@ -23,21 +21,18 @@ const useWorkingDiffComparisonStore = create((set) =
select: (input) => set((state) => selectWorkingDiffComparisonInState(state, input)),
}));
-export function useWorkingDiffComparison(
- input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
-): {
+export function useWorkingDiffComparison(input: WorkingDiffCheckoutIdentity): {
comparison: WorkingDiffComparison;
selectComparison: (comparison: WorkingDiffComparison) => void;
} {
- const { serverId, workspaceId, cwd, isDirty } = input;
+ const { serverId, workspaceId, cwd } = input;
const comparison = useWorkingDiffComparisonStore((state) =>
- resolveWorkingDiffComparisonFromState(state, { serverId, workspaceId, cwd, isDirty }),
+ resolveWorkingDiffComparisonFromState(state, { serverId, workspaceId, cwd }),
);
const select = useWorkingDiffComparisonStore((state) => state.select);
const selectComparison = useCallback(
- (next: WorkingDiffComparison) =>
- select({ serverId, workspaceId, cwd, isDirty, comparison: next }),
- [cwd, isDirty, select, serverId, workspaceId],
+ (next: WorkingDiffComparison) => select({ serverId, workspaceId, cwd, comparison: next }),
+ [cwd, select, serverId, workspaceId],
);
return { comparison, selectComparison };
}
@@ -45,28 +40,17 @@ export function useWorkingDiffComparison(
export function selectWorkingDiffComparison(
input: WorkingDiffCheckoutIdentity & {
comparison: WorkingDiffComparison;
- isDirty: boolean;
},
): void {
useWorkingDiffComparisonStore.getState().select(input);
}
export function resolveWorkingDiffComparison(
- input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
+ input: WorkingDiffCheckoutIdentity,
): WorkingDiffComparison {
return resolveWorkingDiffComparisonFromState(useWorkingDiffComparisonStore.getState(), input);
}
-export function expireWorkingDiffComparisons(input: {
- serverId: string;
- cwd: string;
- isDirty: boolean;
-}): void {
- useWorkingDiffComparisonStore.setState((state) =>
- expireWorkingDiffComparisonsInState(state, input),
- );
-}
-
export function resetWorkingDiffComparisons(): void {
useWorkingDiffComparisonStore.setState({ overrides: {} });
}
diff --git a/packages/app/src/git/working-diff-comparison/state.test.ts b/packages/app/src/git/working-diff-comparison/state.test.ts
index f86038bdc1..95fbe47ffb 100644
--- a/packages/app/src/git/working-diff-comparison/state.test.ts
+++ b/packages/app/src/git/working-diff-comparison/state.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
- expireWorkingDiffComparisonsInState,
resolveWorkingDiffComparisonFromState,
selectWorkingDiffComparisonInState,
type WorkingDiffComparisonState,
@@ -26,79 +25,22 @@ describe("working diff comparison", () => {
);
});
- it("defaults from checkout dirtiness and honors a matching manual selection", () => {
- expect(
- resolveWorkingDiffComparisonFromState(emptyState(), { ...checkout, isDirty: true }),
- ).toBe("uncommitted");
- expect(
- resolveWorkingDiffComparisonFromState(emptyState(), { ...checkout, isDirty: false }),
- ).toBe("base");
-
- const selected = selectWorkingDiffComparisonInState(emptyState(), {
- ...checkout,
- comparison: "base",
- isDirty: true,
- });
- expect(resolveWorkingDiffComparisonFromState(selected, { ...checkout, isDirty: true })).toBe(
- "base",
- );
+ it.each([true, false])("defaults to All changes when checkout dirtiness is %s", (isDirty) => {
+ const status = { ...checkout, isDirty };
+ expect(resolveWorkingDiffComparisonFromState(emptyState(), status)).toBe("all");
});
- it("masks and expires stale selections for every workspace on the checkout", () => {
- let state = selectWorkingDiffComparisonInState(emptyState(), {
- ...checkout,
- comparison: "base",
- isDirty: true,
- });
- state = selectWorkingDiffComparisonInState(state, {
- ...checkout,
- workspaceId: "workspace-2",
- comparison: "uncommitted",
- isDirty: false,
- });
- state = selectWorkingDiffComparisonInState(state, {
- serverId: "server-2",
- workspaceId: "workspace-3",
- cwd: "/other",
- comparison: "base",
- isDirty: true,
- });
-
- expect(resolveWorkingDiffComparisonFromState(state, { ...checkout, isDirty: false })).toBe(
- "base",
- );
- const expired = expireWorkingDiffComparisonsInState(state, {
- serverId: checkout.serverId,
- cwd: checkout.cwd,
- isDirty: false,
- });
- expect(expired.overrides[workingDiffComparisonKey(checkout)]).toBeUndefined();
- expect(
- expired.overrides[workingDiffComparisonKey({ ...checkout, workspaceId: "workspace-2" })],
- ).toBeDefined();
- expect(
- expired.overrides[
- workingDiffComparisonKey({
- serverId: "server-2",
- workspaceId: "workspace-3",
- cwd: "/other",
- })
- ],
- ).toBeDefined();
- });
-
- it("keeps state identity when nothing expires", () => {
- const state = selectWorkingDiffComparisonInState(emptyState(), {
- ...checkout,
- comparison: "base",
- isDirty: true,
- });
- expect(
- expireWorkingDiffComparisonsInState(state, {
- serverId: checkout.serverId,
- cwd: checkout.cwd,
- isDirty: true,
- }),
- ).toBe(state);
- });
+ it.each(["all", "base", "uncommitted"] as const)(
+ "keeps the selected %s comparison",
+ (comparison) => {
+ const selected = selectWorkingDiffComparisonInState(emptyState(), {
+ ...checkout,
+ comparison,
+ });
+ expect(resolveWorkingDiffComparisonFromState(selected, checkout)).toBe(comparison);
+ expect(
+ resolveWorkingDiffComparisonFromState(selected, { ...checkout, workspaceId: "other" }),
+ ).toBe("all");
+ },
+ );
});
diff --git a/packages/app/src/git/working-diff-comparison/state.ts b/packages/app/src/git/working-diff-comparison/state.ts
index 5c7c141376..cf077a7316 100644
--- a/packages/app/src/git/working-diff-comparison/state.ts
+++ b/packages/app/src/git/working-diff-comparison/state.ts
@@ -1,10 +1,7 @@
-export type WorkingDiffComparison = "uncommitted" | "base";
+export type WorkingDiffComparison = "uncommitted" | "base" | "all";
export interface WorkingDiffComparisonOverride {
- serverId: string;
- cwd: string;
comparison: WorkingDiffComparison;
- isDirtyAtSelection: boolean;
}
export interface WorkingDiffComparisonState {
@@ -34,17 +31,13 @@ export function selectWorkingDiffComparisonInState(
state: WorkingDiffComparisonState,
input: WorkingDiffCheckoutIdentity & {
comparison: WorkingDiffComparison;
- isDirty: boolean;
},
): WorkingDiffComparisonState {
return {
overrides: {
...state.overrides,
[workingDiffComparisonKey(input)]: {
- serverId: input.serverId.trim(),
- cwd: normalizeCwd(input.cwd),
comparison: input.comparison,
- isDirtyAtSelection: input.isDirty,
},
},
};
@@ -52,34 +45,7 @@ export function selectWorkingDiffComparisonInState(
export function resolveWorkingDiffComparisonFromState(
state: WorkingDiffComparisonState,
- input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
+ input: WorkingDiffCheckoutIdentity,
): WorkingDiffComparison {
- const override = state.overrides[workingDiffComparisonKey(input)];
- // Status can render before boundary expiry runs, so resolution must also mask a stale
- // selection under any ordering of the two updates.
- if (override?.isDirtyAtSelection === input.isDirty) {
- return override.comparison;
- }
- return input.isDirty ? "uncommitted" : "base";
-}
-
-export function expireWorkingDiffComparisonsInState(
- state: WorkingDiffComparisonState,
- input: { serverId: string; cwd: string; isDirty: boolean },
-): WorkingDiffComparisonState {
- // Expire at the status boundary so selections cannot return after an unmounted surface
- // misses a dirty-state transition.
- const staleKeys = Object.entries(state.overrides)
- .filter(
- ([, override]) =>
- override.serverId === input.serverId.trim() &&
- override.cwd === normalizeCwd(input.cwd) &&
- override.isDirtyAtSelection !== input.isDirty,
- )
- .map(([key]) => key);
- if (staleKeys.length === 0) return state;
-
- const overrides = { ...state.overrides };
- for (const key of staleKeys) delete overrides[key];
- return { overrides };
+ return state.overrides[workingDiffComparisonKey(input)]?.comparison ?? "all";
}
diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts
index 1bc4cf2135..7aa85c1faf 100644
--- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts
+++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts
@@ -9,6 +9,7 @@ import {
buildSidebarWorkspacePlacementModel,
buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates,
+ createSidebarWorkspacePlacementSelector,
createSidebarWorkspaceEntry,
deriveProjectStatusBucket,
deriveSidebarLoadingState,
@@ -20,6 +21,8 @@ import {
function workspaceWithForge(forge: string | undefined, prUrl: string): WorkspaceDescriptor {
return {
+ createdAt: null,
+ lastUserMessageAt: null,
id: "ws-1",
projectId: "proj",
projectDisplayName: "repo",
@@ -150,6 +153,8 @@ function workspace(input: {
statusEnteredAt?: Date | null;
}): WorkspaceDescriptor {
return {
+ createdAt: null,
+ lastUserMessageAt: null,
id: input.id,
projectId: input.projectId,
projectDisplayName: input.projectDisplayName,
@@ -610,13 +615,12 @@ describe("computeSidebarOrderUpdates", () => {
const updates = computeSidebarOrderUpdates({
projects: [],
persistedProjectOrder: ["stale-project"],
- getWorkspaceOrder: () => [],
});
expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] });
});
- it("appends unseen projects while putting unseen workspaces before the saved order", () => {
+ it("appends unseen projects without changing saved project order", () => {
const projects = [
sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1", "ws-2"] }),
sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-3"] }),
@@ -625,36 +629,9 @@ describe("computeSidebarOrderUpdates", () => {
const updates = computeSidebarOrderUpdates({
projects,
persistedProjectOrder: ["project-a"],
- getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : []),
});
expect(updates.projectOrder).toEqual(["project-a", "project-b"]);
- expect(updates.workspaceOrders).toEqual([
- { projectViewKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] },
- { projectViewKey: "project-b", order: ["srv:ws-3"] },
- ]);
- });
-
- it("preserves the saved workspace order behind multiple newly discovered workspaces", () => {
- const projects = [
- sidebarProject({
- projectKey: "project-a",
- workspaceKeys: ["newest", "newer", "old-a", "old-b"],
- }),
- ];
-
- const updates = computeSidebarOrderUpdates({
- projects,
- persistedProjectOrder: ["project-a"],
- getWorkspaceOrder: () => ["srv:old-b", "srv:old-a"],
- });
-
- expect(updates.workspaceOrders).toEqual([
- {
- projectViewKey: "project-a",
- order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"],
- },
- ]);
});
it("returns no project-order update when persisted order already covers visible keys", () => {
@@ -666,11 +643,9 @@ describe("computeSidebarOrderUpdates", () => {
const updates = computeSidebarOrderUpdates({
projects,
persistedProjectOrder: ["project-b", "project-a"],
- getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : ["srv:ws-2"]),
});
expect(updates.projectOrder).toBeNull();
- expect(updates.workspaceOrders).toEqual([]);
});
});
@@ -993,3 +968,31 @@ describe("deriveProjectStatusBucket", () => {
).toBe("done");
});
});
+
+it("retains unrelated projects and keyed row placements when one project reorders", () => {
+ const select = createSidebarWorkspacePlacementSelector();
+ const a = project({ projectKey: "a", workspaceKeys: ["srv:a1", "srv:a2"] });
+ const b = project({ projectKey: "b", workspaceKeys: ["srv:b1"] });
+ const before = select([a, b]);
+ const after = select([{ ...a, workspaceKeys: ["srv:a2", "srv:a1"] }, b]);
+ expect(after.projects[1]).toBe(before.projects[1]);
+ 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([]);
+});
diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts
index e004c3fb89..da1086da45 100644
--- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts
+++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts
@@ -1,3 +1,5 @@
+import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
+import equal from "fast-deep-equal";
import type { PrHint } from "@/git/pr-hint";
import { selectPrHintFromStatus } from "@/git/pr-hint";
import { type HostProjectListItem } from "@/projects/host-project-model";
@@ -123,11 +125,6 @@ export function areSidebarWorkspaceSessionsEqual(
return true;
}
-interface EffectiveWorkspaceStatus {
- status: WorkspaceDescriptor["status"];
- enteredAt: Date | null;
-}
-
function projectNameForWorkspace(workspace: WorkspaceDescriptor): string {
return (
workspace.projectCustomName ??
@@ -188,51 +185,6 @@ export function createSidebarWorkspaceEntry(input: {
const EMPTY_WORKSPACE_LABELS: string[] = [];
-function deriveEffectiveWorkspaceStatus(input: {
- serverId: string;
- workspace: WorkspaceDescriptor;
- pendingCreateAttempts?: Record;
- workspaceAgentActivity?: ReadonlyMap;
-}): 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 | 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;
workspaceAgentActivity: Map;
@@ -287,6 +239,34 @@ export function deriveProjectStatusBucket(input: {
return aggregateSidebarStateBuckets(buckets);
}
+/** One bounded projection per sidebar; unchanged placements survive project reordering. */
+export function createSidebarWorkspacePlacementSelector(): (
+ projects: readonly HostProjectListItem[],
+) => SidebarWorkspacePlacementModel {
+ let previous: SidebarWorkspacePlacementModel | null = null;
+ return (projects) => {
+ const next = buildSidebarWorkspacePlacementModel({ projects });
+ const previousProjects = new Map(
+ previous?.projects.map((project) => [project.viewKey, project]),
+ );
+ const previousWorkspaces = new Map(
+ previous?.workspaces.map((workspace) => [workspace.workspaceKey, workspace]),
+ );
+ next.projects = next.projects.map((project) => {
+ const prior = previousProjects.get(project.viewKey);
+ if (prior && equal(prior, project)) return prior;
+ project.workspaces = project.workspaces.map((workspace) => {
+ const priorWorkspace = previousWorkspaces.get(workspace.workspaceKey);
+ return priorWorkspace && equal(priorWorkspace, workspace) ? priorWorkspace : workspace;
+ });
+ return project;
+ });
+ next.workspaces = next.projects.flatMap((project) => project.workspaces);
+ previous = next;
+ return next;
+ };
+}
+
export function buildSidebarWorkspacePlacementModel(input: {
projects: readonly HostProjectListItem[];
}): SidebarWorkspacePlacementModel {
@@ -580,7 +560,7 @@ export interface SidebarOrderUpdates {
export function computeSidebarOrderUpdates(input: {
projects: SidebarProjectEntry[];
persistedProjectOrder: string[];
- getWorkspaceOrder: (projectViewKey: string) => string[];
+ getWorkspaceOrder?: (projectViewKey: string) => string[];
}): SidebarOrderUpdates {
if (input.projects.length === 0) {
return { projectOrder: null, workspaceOrders: [] };
@@ -594,6 +574,7 @@ export function computeSidebarOrderUpdates(input: {
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,
diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts
index c5fd59d53e..a30b27ce8f 100644
--- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts
+++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts
@@ -61,8 +61,8 @@ describe("ACP provider catalog", () => {
it("offers MiniMax Code through its pinned public ACP package", () => {
expect(findProvider("minimax-code")).toMatchObject({
title: "MiniMax Code",
- version: "0.1.2",
- command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"],
+ version: "0.6.13",
+ command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
});
expect(findProvider("minimax-code").iconSvg).toContain("