mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 04:15:01 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9395e2d1 | ||
|
|
85ce1aa828 | ||
|
|
d13438ba50 | ||
|
|
cf9eaa8d29 | ||
|
|
ec52097924 | ||
|
|
a30b784eb2 | ||
|
|
af6ca46ae3 | ||
|
|
8dc979ec2f | ||
|
|
70aeb7b45b | ||
|
|
8b3b2a9ea3 | ||
|
|
b6952995be | ||
|
|
c558af91eb | ||
|
|
7791dcee3b | ||
|
|
e532a24327 | ||
|
|
1aba2a5f23 | ||
|
|
aead84bf0f | ||
|
|
7980ad008b | ||
|
|
7c35ad90b9 | ||
|
|
9eccb4722b | ||
|
|
536846eb60 | ||
|
|
cc16ac6ac4 | ||
|
|
fd9aebd622 | ||
|
|
21709812bb | ||
|
|
d426088c3f | ||
|
|
b5c25e5c10 | ||
|
|
525ee7f222 | ||
|
|
065c487a90 | ||
|
|
4df5307eaa | ||
|
|
aa7cabb455 | ||
|
|
9e607a3aa3 | ||
|
|
68818067f2 | ||
|
|
2240d4effe | ||
|
|
18c002cbf2 | ||
|
|
47a7463182 | ||
|
|
a4878656e2 |
+10
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.0-beta.1 - 2026-11-10
|
||||
## 0.16.0-beta.2 - 2026-11-11
|
||||
|
||||
- **Background tasks:** Paseo now shows the background tasks an agent is running, such as shells and monitors, above the composer, with a Stop button for each. Works with Claude Code, Codex and OpenCode v2. ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162) by [@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- **Plugin sidebar views:** A plugin can replace the sidebar's content with its own layout of workspaces, agents, projects and terminals across every connected host. Pick the view per device under Settings → Sidebar; Paseo's sidebar header and footer stay in place. ([#8906](https://github.com/getpaseo/paseo/pull/8906))
|
||||
@@ -30,6 +30,8 @@
|
||||
- Changed the slash command menu to show loading and error states with Retry, and to stop selecting `/exit` by default ([#8905](https://github.com/getpaseo/paseo/pull/8905))
|
||||
- Changed the workspace header and tabs to switch to compact controls when Explorer or a split pane narrows the workspace ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088) by [@zeyugao](https://github.com/zeyugao))
|
||||
- Changed sending a message to a Pi agent whose process has exited to fail immediately with an instruction to reload the agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
|
||||
- Changed Rename workspace in the sidebar on desktop and wide web layouts to edit the title in place instead of opening a dialog ([#9280](https://github.com/getpaseo/paseo/pull/9280))
|
||||
- Changed What's new and the website changelog to show a release's highlights first, with the full list behind Show all changes ([#9281](https://github.com/getpaseo/paseo/pull/9281))
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -37,6 +39,7 @@
|
||||
- Fixed chats with short collapsed turns opening with empty space until you scrolled ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
|
||||
- Fixed Collapse turns hiding an earlier reply inside a later turn's summary line after an idle Claude, Codex or OpenCode agent was resumed ([#9098](https://github.com/getpaseo/paseo/pull/9098))
|
||||
- Fixed the missing space between a prompt and the reply or collapsed-turn summary line below it ([#9009](https://github.com/getpaseo/paseo/pull/9009))
|
||||
- Fixed a long user message showing without its fade and Show more for one frame when a chat opened ([#9302](https://github.com/getpaseo/paseo/pull/9302))
|
||||
- Fixed Markdown tables showing as literal pipes when written directly below a paragraph with no blank line ([#8485](https://github.com/getpaseo/paseo/pull/8485) by [@ragokan](https://github.com/ragokan), [@suk-6](https://github.com/suk-6), [@thsrite](https://github.com/thsrite))
|
||||
- Fixed linked badge images that share a line with text rendering as full-width blocks on web and desktop ([#8530](https://github.com/getpaseo/paseo/pull/8530))
|
||||
- Fixed View diagram opening fullscreen instead of returning to the inline Mermaid diagram after View source ([#8932](https://github.com/getpaseo/paseo/pull/8932) by [@luoph](https://github.com/luoph))
|
||||
@@ -48,6 +51,7 @@
|
||||
- Fixed Add host sending you to Settings from the sidebar and New workspace ([#8512](https://github.com/getpaseo/paseo/pull/8512))
|
||||
- Fixed a failed agent's red dot and Failed grouping staying in the sidebar after opening the agent or choosing Mark as read ([#8831](https://github.com/getpaseo/paseo/pull/8831))
|
||||
- Fixed a workspace's unread indicator staying on after it was cleared while the agent's runtime was closed ([#9087](https://github.com/getpaseo/paseo/pull/9087))
|
||||
- Fixed unarchiving a workspace not restoring the agents that were archived with it ([#9210](https://github.com/getpaseo/paseo/pull/9210))
|
||||
- Fixed the context window popover closing by itself whenever the chat scrolled during a streaming reply ([#8742](https://github.com/getpaseo/paseo/pull/8742) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed the sidebar usage summary updating only when Usage was opened ([#7806](https://github.com/getpaseo/paseo/pull/7806) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed tooltips and menus above their trigger briefly covering it on Android when their content grew after opening ([#9247](https://github.com/getpaseo/paseo/pull/9247))
|
||||
@@ -73,11 +77,13 @@
|
||||
- Fixed Codex "Apply file changes" approvals showing no file name or diff before you accept ([#8585](https://github.com/getpaseo/paseo/pull/8585) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed the timeline showing only the first file's diff when Codex edits several files in one patch ([#8613](https://github.com/getpaseo/paseo/pull/8613) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed Codex session discovery and startup intermittently failing with a SQLite initialization error when several Codex operations start at once ([#9008](https://github.com/getpaseo/paseo/pull/9008) by [@colonelpanic8](https://github.com/colonelpanic8), [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Fixed Codex reconnect attempts and warnings not appearing in the timeline ([#9370](https://github.com/getpaseo/paseo/pull/9370) by [@whatamelon](https://github.com/whatamelon))
|
||||
- Fixed OpenCode conversations that were mid-turn during a daemon restart showing idle and ignoring Stop ([#8698](https://github.com/getpaseo/paseo/pull/8698))
|
||||
- Fixed OpenCode being told you declined a tool call when you sent a message while its approval was pending ([#8477](https://github.com/getpaseo/paseo/pull/8477))
|
||||
- Fixed reopened Pi conversations missing the messages from before a context compaction ([#8523](https://github.com/getpaseo/paseo/pull/8523))
|
||||
- Fixed archived Pi chats failing to open with `spawn pi ENOENT` after their working folder was removed ([#8442](https://github.com/getpaseo/paseo/pull/8442) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed reading an archived Pi chat leaving a Pi process running when idle runtime cleanup is disabled ([#8554](https://github.com/getpaseo/paseo/pull/8554) by [@rylwin](https://github.com/rylwin))
|
||||
- Fixed resumed pi-subagents runs missing from the Subagents track ([#9261](https://github.com/getpaseo/paseo/pull/9261) by [@xxachangxx](https://github.com/xxachangxx))
|
||||
- Fixed a prompt sent to an ACP agent whose process has exited staying in running forever instead of failing with an error ([#8406](https://github.com/getpaseo/paseo/pull/8406) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed an ACP agent staying stuck on "waiting for permission" after it abandoned an approval request and ended its turn ([#8548](https://github.com/getpaseo/paseo/pull/8548) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed changing the mode of an ACP agent failing with `invalid_params` after the agent sends a config update ([#8912](https://github.com/getpaseo/paseo/pull/8912) by [@a-sh](https://github.com/a-sh))
|
||||
@@ -109,10 +115,13 @@
|
||||
### Plugins
|
||||
|
||||
- Added plugin subscriptions, which stream live events from the daemon to a plugin's panels, and binary (`Uint8Array`) payloads for plugin RPCs and events ([#8527](https://github.com/getpaseo/paseo/pull/8527))
|
||||
- Added an agent's background task count (`backgroundWork.count`) and `background` flag to plugin agent snapshots, and a `background` flag to agent hooks ([#9362](https://github.com/getpaseo/paseo/pull/9362))
|
||||
- Changed Settings → Plugins to open a plugin's settings from a cog button, with a tab per settings screen ([#8675](https://github.com/getpaseo/paseo/pull/8675))
|
||||
- Added an upward chevron to plugin composer pills that open a menu or popover ([#8673](https://github.com/getpaseo/paseo/pull/8673))
|
||||
- Changed daemon startup to launch configured plugins concurrently instead of one after another ([#8427](https://github.com/getpaseo/paseo/pull/8427) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed registry plugin installs failing with "npm artifact changed since review" when the host's npm uses a registry mirror ([#8756](https://github.com/getpaseo/paseo/pull/8756) by [@tlipoca9](https://github.com/tlipoca9))
|
||||
- Fixed a plugin the daemon terminated for an invalid message staying listed with no exit reported ([#9285](https://github.com/getpaseo/paseo/pull/9285) by [@Brandon168](https://github.com/Brandon168))
|
||||
- Fixed ACP provider plugins dropping the token usage an agent reports in its prompt response ([#9396](https://github.com/getpaseo/paseo/pull/9396) by [@Myasuka](https://github.com/Myasuka))
|
||||
- Fixed the published `@getpaseo/*` npm packages declaring no license; they now declare Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816) by [@mdlawson](https://github.com/mdlawson), [@ragokan](https://github.com/ragokan))
|
||||
- Fixed forking from an earlier reply failing with "This checkpoint changed after it was created" after a plugin updated one of its timeline cards ([#8746](https://github.com/getpaseo/paseo/pull/8746) by [@monarchmaisuriya](https://github.com/monarchmaisuriya))
|
||||
- Fixed Collapse turns leaving a turn open, or folding to an earlier reply, when a plugin renders the final reply ([#8811](https://github.com/getpaseo/paseo/pull/8811) by [@geoqiao](https://github.com/geoqiao))
|
||||
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.0-beta.1 - 2026-11-10
|
||||
## 0.16.0-beta.2 - 2026-11-11
|
||||
|
||||
- **后台任务**:Paseo 现在会在 Composer 上方显示 Agent 正在运行的后台任务,例如 shell 和监视器,每项都带“停止”按钮。支持 Claude Code、Codex 和 OpenCode v2。 ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162),贡献者:[@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- **插件侧边栏视图**:插件可以用自己的布局替换侧边栏内容,展示所有已连接主机上的工作区、Agent、项目和终端。在“设置 → 侧边栏”中为每台设备选择视图;Paseo 的侧边栏页眉和页脚保持不变。 ([#8906](https://github.com/getpaseo/paseo/pull/8906))
|
||||
@@ -30,6 +30,8 @@
|
||||
- 斜杠命令菜单现在显示加载和错误状态并提供“重试”,且不再默认选中 `/exit` ([#8905](https://github.com/getpaseo/paseo/pull/8905))
|
||||
- 文件浏览器(Explorer)或分屏使工作区变窄时,工作区页眉和标签页改用紧凑控件 ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088),贡献者:[@zeyugao](https://github.com/zeyugao))
|
||||
- 向进程已退出的 Pi Agent 发送消息时立即失败,并提示重新加载该 Agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
|
||||
- 在桌面端和宽屏网页布局中,侧边栏的“重命名 workspace”改为在原处编辑标题,不再打开对话框 ([#9280](https://github.com/getpaseo/paseo/pull/9280))
|
||||
- “更新内容”和网站更新日志改为先显示每个版本的亮点,完整列表收在“显示全部变更”之后 ([#9281](https://github.com/getpaseo/paseo/pull/9281))
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -37,6 +39,7 @@
|
||||
- 修复包含较短折叠轮次的聊天打开时留有空白、需滚动后才填满的问题 ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
|
||||
- 修复空闲的 Claude、Codex 或 OpenCode Agent 恢复后,“折叠轮次”把较早的回复藏进后一轮摘要行的问题 ([#9098](https://github.com/getpaseo/paseo/pull/9098))
|
||||
- 修复提示词与其下方的回复或折叠轮次摘要行之间缺少间距的问题 ([#9009](https://github.com/getpaseo/paseo/pull/9009))
|
||||
- 修复聊天打开时,较长的用户消息有一帧不显示渐隐效果和“展开”的问题 ([#9302](https://github.com/getpaseo/paseo/pull/9302))
|
||||
- 修复紧跟在段落之后、中间没有空行的 Markdown 表格显示为原始竖线的问题 ([#8485](https://github.com/getpaseo/paseo/pull/8485),贡献者:[@ragokan](https://github.com/ragokan)、[@suk-6](https://github.com/suk-6)、[@thsrite](https://github.com/thsrite))
|
||||
- 修复网页版和桌面端中与文字同行的带链接徽章图片被渲染成整行宽度的问题 ([#8530](https://github.com/getpaseo/paseo/pull/8530))
|
||||
- 修复“查看源码”后点击“查看图表”会打开全屏、而不是回到内联 Mermaid 图表的问题 ([#8932](https://github.com/getpaseo/paseo/pull/8932),贡献者:[@luoph](https://github.com/luoph))
|
||||
@@ -48,6 +51,7 @@
|
||||
- 修复从侧边栏和“新建工作区”点击“添加主机”会跳转到“设置”的问题 ([#8512](https://github.com/getpaseo/paseo/pull/8512))
|
||||
- 修复打开失败的 Agent 或选择 Mark as read 后,其红点和“失败”分组仍留在侧边栏的问题 ([#8831](https://github.com/getpaseo/paseo/pull/8831))
|
||||
- 修复在 Agent 运行时已关闭的情况下清除未读后,工作区的未读指示仍然亮着的问题 ([#9087](https://github.com/getpaseo/paseo/pull/9087))
|
||||
- 修复取消归档工作区时未恢复随之归档的 Agent 的问题 ([#9210](https://github.com/getpaseo/paseo/pull/9210))
|
||||
- 修复流式回复期间聊天一滚动上下文窗口浮层就自行关闭的问题 ([#8742](https://github.com/getpaseo/paseo/pull/8742),贡献者:[@3ae3ae](https://github.com/3ae3ae))
|
||||
- 修复侧边栏用量摘要只在打开“使用情况”时才更新的问题 ([#7806](https://github.com/getpaseo/paseo/pull/7806),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 修复 Android 上位于触发元素上方的提示和菜单在打开后内容变多时短暂盖住触发元素的问题 ([#9247](https://github.com/getpaseo/paseo/pull/9247))
|
||||
@@ -73,11 +77,13 @@
|
||||
- 修复 Codex 的“Apply file changes”审批在你接受之前不显示文件名和 diff 的问题 ([#8585](https://github.com/getpaseo/paseo/pull/8585),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 修复 Codex 在一个补丁中编辑多个文件时,时间线只显示第一个文件的 diff 的问题 ([#8613](https://github.com/getpaseo/paseo/pull/8613),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 修复多个 Codex 操作同时启动时,Codex 会话发现和启动偶发 SQLite 初始化错误的问题 ([#9008](https://github.com/getpaseo/paseo/pull/9008),贡献者:[@colonelpanic8](https://github.com/colonelpanic8)、[@michaelmwu](https://github.com/michaelmwu))
|
||||
- 修复 Codex 的重连尝试和警告未显示在时间线中的问题 ([#9370](https://github.com/getpaseo/paseo/pull/9370),贡献者:[@whatamelon](https://github.com/whatamelon))
|
||||
- 修复 daemon 重启时正处于轮次中的 OpenCode 对话显示为空闲并忽略“停止”的问题 ([#8698](https://github.com/getpaseo/paseo/pull/8698))
|
||||
- 修复审批待处理期间你发送消息时,OpenCode 被告知你拒绝了工具调用的问题 ([#8477](https://github.com/getpaseo/paseo/pull/8477))
|
||||
- 修复重新打开的 Pi 对话缺少上下文压缩之前的消息的问题 ([#8523](https://github.com/getpaseo/paseo/pull/8523))
|
||||
- 修复工作文件夹被移除后,已归档的 Pi 聊天因 `spawn pi ENOENT` 无法打开的问题 ([#8442](https://github.com/getpaseo/paseo/pull/8442),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复关闭空闲运行时清理的情况下,读取已归档的 Pi 聊天会遗留一个 Pi 进程的问题 ([#8554](https://github.com/getpaseo/paseo/pull/8554),贡献者:[@rylwin](https://github.com/rylwin))
|
||||
- 修复恢复的 pi-subagents 运行未出现在 Subagent 栏中的问题 ([#9261](https://github.com/getpaseo/paseo/pull/9261),贡献者:[@xxachangxx](https://github.com/xxachangxx))
|
||||
- 修复向进程已退出的 ACP Agent 发送提示词后一直停留在运行中、而不是报错失败的问题 ([#8406](https://github.com/getpaseo/paseo/pull/8406),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复 ACP Agent 放弃审批请求并结束轮次后一直卡在“waiting for permission”的问题 ([#8548](https://github.com/getpaseo/paseo/pull/8548),贡献者:[@3ae3ae](https://github.com/3ae3ae))
|
||||
- 修复 ACP Agent 发送配置更新后,更改其模式因 `invalid_params` 失败的问题 ([#8912](https://github.com/getpaseo/paseo/pull/8912),贡献者:[@a-sh](https://github.com/a-sh))
|
||||
@@ -109,10 +115,13 @@
|
||||
### 插件
|
||||
|
||||
- 新增插件订阅,可将 daemon 的实时事件流式推送到插件面板;插件 RPC 和事件支持二进制(`Uint8Array`)负载 ([#8527](https://github.com/getpaseo/paseo/pull/8527))
|
||||
- 插件的 Agent 快照新增后台任务数量(`backgroundWork.count`)和 `background` 标记,Agent 钩子新增 `background` 标记 ([#9362](https://github.com/getpaseo/paseo/pull/9362))
|
||||
- “设置 → 插件”改为通过齿轮按钮打开插件设置,每个设置页面对应一个标签页 ([#8675](https://github.com/getpaseo/paseo/pull/8675))
|
||||
- 为会打开菜单或浮层的插件 Composer 胶囊按钮新增向上的箭头 ([#8673](https://github.com/getpaseo/paseo/pull/8673))
|
||||
- daemon 启动时改为并发启动已配置的插件,不再逐个启动 ([#8427](https://github.com/getpaseo/paseo/pull/8427),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复主机的 npm 使用镜像源时,从插件库安装插件因“npm artifact changed since review”失败的问题 ([#8756](https://github.com/getpaseo/paseo/pull/8756),贡献者:[@tlipoca9](https://github.com/tlipoca9))
|
||||
- 修复 daemon 因无效消息终止插件后,该插件仍留在列表中且未报告退出的问题 ([#9285](https://github.com/getpaseo/paseo/pull/9285),贡献者:[@Brandon168](https://github.com/Brandon168))
|
||||
- 修复 ACP Provider 插件丢弃 Agent 在提示词响应中报告的 token 用量的问题 ([#9396](https://github.com/getpaseo/paseo/pull/9396),贡献者:[@Myasuka](https://github.com/Myasuka))
|
||||
- 修复已发布的 `@getpaseo/*` npm 包未声明许可证的问题;现在声明为 Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816),贡献者:[@mdlawson](https://github.com/mdlawson)、[@ragokan](https://github.com/ragokan))
|
||||
- 修复插件更新了自己的某张时间线卡片后,从较早的回复分叉因“This checkpoint changed after it was created”失败的问题 ([#8746](https://github.com/getpaseo/paseo/pull/8746),贡献者:[@monarchmaisuriya](https://github.com/monarchmaisuriya))
|
||||
- 修复由插件渲染最终回复时,“折叠轮次”让轮次保持展开或折叠到较早回复的问题 ([#8811](https://github.com/getpaseo/paseo/pull/8811),贡献者:[@geoqiao](https://github.com/geoqiao))
|
||||
|
||||
@@ -112,6 +112,15 @@ need an artifact. `android:clear` removes the generated Android project.
|
||||
|
||||
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
|
||||
|
||||
With an emulator or device connected, run the hardware keyboard's window-dispatch regression tests
|
||||
from `packages/app/android`:
|
||||
|
||||
```bash
|
||||
./gradlew :paseo-hardware-keyboard:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
These check terminal arrow ownership, held-key repeats and modifiers, and ordinary text-field editing.
|
||||
|
||||
### Diagnostic logs without ADB
|
||||
|
||||
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
|
||||
|
||||
@@ -44,6 +44,8 @@ Foreground is for the thing being acted on: row titles, section headings, the se
|
||||
|
||||
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
|
||||
|
||||
A theme states places and identity; `packages/app/src/styles/derive-colors.ts` derives their relationships. Sidebar hover and selected fills step from the sidebar toward the foreground. For plugin themes, passive text on those states derives against the actual state surface; page tertiary text is a separate relationship. Explicit plugin semantic overrides take precedence over derived defaults. Built-in themes retain their chosen passive text. `packages/app/src/styles/theme-contrast.test.ts` checks the built-in and representative plugin palettes against contrast floors.
|
||||
|
||||
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
||||
|
||||
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
|
||||
|
||||
@@ -113,6 +113,38 @@ host that is either a known cloud host or one the CLI is already authenticated
|
||||
to. Adapter probes must not make anonymous HTTP requests to remote-derived
|
||||
hosts, and adapters must not route credentials to an unauthenticated host.
|
||||
|
||||
### Web links: `forgeWeb`
|
||||
|
||||
The daemon publishes `forgeWeb` on the workspace descriptor and on
|
||||
`checkout_status_response`. The app builds every forge URL from it instead of
|
||||
parsing `origin` itself.
|
||||
|
||||
- `host` is the browsable host: an `ssh -G` alias such as `github.com-work` and
|
||||
a cloud SSH host such as `ssh.github.com` resolve to the cloud host; self-hosted
|
||||
hosts pass through.
|
||||
- `referenceRepository` is the repository that owns issue and change-request
|
||||
numbers: the parent when `origin` is a fork. `#123` links there; `owner/repo#123`
|
||||
keeps the repository you wrote.
|
||||
- Branch and file links stay on the `origin` repository. A feature branch usually
|
||||
exists only on the fork.
|
||||
|
||||
An adapter opts into `referenceRepository` with the optional
|
||||
`ForgeService.getReferenceRepository`. GitHub reads `parent` from `gh repo view`,
|
||||
GitLab reads `forked_from_project` from `glab api`, and the Gitea family reads
|
||||
`parent` from `tea api`. The lookup always names the `origin` repository
|
||||
explicitly, so it does not depend on `gh`'s default-repository choice, and it
|
||||
rejects on any CLI failure. `packages/server/src/server/forge-web.ts` keeps a
|
||||
successful answer for as long as the remote stays the same. The first snapshot
|
||||
publishes `{ host }` immediately and the reference repository follows in a later
|
||||
update, so a slow or signed-out forge CLI never holds back the checkout's git
|
||||
state or pull request status. A failed lookup (signed out, network down) leaves
|
||||
links on `origin` and is retried by the next refresh or pull request status poll
|
||||
once a minute has passed.
|
||||
|
||||
Daemons older than v0.16.0 omit the field; the app then derives the host from the
|
||||
remote and links `#N` to `origin`. That fallback is tagged `COMPAT(forgeWeb)` in
|
||||
`packages/app/src/git/forge-url.ts`.
|
||||
|
||||
## App
|
||||
|
||||
Each app forge splits into two modules so pure logic never imports the client
|
||||
|
||||
@@ -20,9 +20,16 @@ settings the native composer does not implement.
|
||||
|
||||
## Native sources
|
||||
|
||||
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
|
||||
that must bypass the focused text control: composer Return and the top overlay's
|
||||
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
|
||||
after the focused text control has already handled the press, so the pipeline's
|
||||
`preventDefault` cannot stop a key from typing. A key the text control would type
|
||||
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
|
||||
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
|
||||
active in the current focus scope, such as Shift+Tab in the composer.
|
||||
`sources/native.ts` computes the claims from the published bindings and recomputes
|
||||
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
|
||||
text controls do not type them. Android takes no claims: a focused `EditText`
|
||||
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
press is still observed by the window.
|
||||
|
||||
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||
@@ -85,6 +92,11 @@ Escape interrupts an agent only in the message input or an unclaimed surface.
|
||||
A focused terminal excludes that binding. The source leaves terminal Escape alone;
|
||||
terminal Escape/Ctrl forwarding is a separate feature.
|
||||
|
||||
Terminal arrows never pass through the shortcut pipeline. The Android hardware
|
||||
module claims them while the terminal's hidden input (`nativeID`) owns the focus.
|
||||
`TextInput.onKeyPress` only sees IME keys, and an arrow that falls through moves
|
||||
the hidden caret and desyncs the next text change, so the pipeline cannot own them.
|
||||
|
||||
## Keys shared with text editing
|
||||
|
||||
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
|
||||
|
||||
@@ -624,6 +624,21 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute workspace row badge items
|
||||
|
||||
The [public reference](../public-docs/plugins/reference.md#workspace-row-badge-items) owns the author API.
|
||||
The host lives in `packages/app/src/plugins/workspace-row-badge-items/`; `WorkspaceMetaRow` draws a host's
|
||||
items after its own.
|
||||
|
||||
- Plugin items render with no wrapper view. The meta line spaces children with `gap`, so an empty
|
||||
wrapper would leave a gap on rows the plugin has nothing to say about. That is also why
|
||||
the row adds no separator around plugin items; a plugin renders its slot.
|
||||
- A row only renders items from plugins on its own host. The Show menu coalesces items across hosts
|
||||
by `<pluginId>/<itemId>`, the key in the `sidebarPluginRowBadgeItems` setting, and places them in
|
||||
their own group below a separator. A missing key is visible.
|
||||
- Previews (Settings > Host appearance) pass no `workspace` to `WorkspaceMetaRow`, so plugin items
|
||||
stay off them.
|
||||
|
||||
## Contribute file panels
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
|
||||
|
||||
+10
-1
@@ -150,6 +150,15 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
|
||||
OMP rewind is a branch, not an edit: `branch` writes a new session file whose header names the
|
||||
previous file as `parentSession` and leaves that previous file untouched. A rewind chain therefore
|
||||
adds one file to `~/.omp/agent/sessions` per step, and `omp --resume` lists all of them. The
|
||||
previous file also keeps whatever head it had recorded, which can be an older branch than the one
|
||||
the user last saw. Treat the provider session file selected by OMP as the authority; do not assume
|
||||
the newest file or the newest message wins. Import listings hand back a file path, so persist the
|
||||
resumed session's own identity (`sessionId`) with that path as `nativeHandle`, not the listed
|
||||
handle as both.
|
||||
|
||||
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
@@ -160,7 +169,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. V2 discovers definitions from each bound session's catalog: plugin-contributed tools can be absent from the global manifest, and an allowlist cannot register a missing definition. Registration is location-wide, so session filtering and caller-scoped execution must remain in place. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Pi resumed subagents — #9200
|
||||
|
||||
## Scope and provenance
|
||||
|
||||
An AI agent ran this QA through Playwright/CDP attached to the **real macOS Electron renderer**, using an isolated desktop-managed daemon. The user authorized the run; this is not a claim that the user personally operated the UI. No production daemon, installed package, or daily host configuration was changed.
|
||||
|
||||
Desktop evidence was captured on the modified **0.15.3 development checkout** at base `e260f93cc7b6098f6b175bd0e5c7eb24d3f03cf4`, with Electron 44.2.0, Pi 1.5.0, and installed pi-subagents 0.86.0. The final branch was subsequently fast-forwarded to `d46ee6558c043f9fb377d20f05ff662ce27a31f6` (0.15.4). The adapter and test file stayed byte-for-byte identical. The lockfile changes only workspace version references; third-party dependency entries did not change. Server declarations were rebuilt and the checks below passed on the final base. **These screenshots are not a 0.15.4 release-package test.**
|
||||
|
||||
## Before and automated checks
|
||||
|
||||
Issue #9200 reports ordinary async spawn appearing in the track while successive successful resumes disappear. The original adapter rejects any `action` and requires top-level `agent`/`task`, which a resume does not supply. A separate file-only transcript deduplication also prevents a new attempt row from following Pi's reused session file.
|
||||
|
||||
[Captured command output](validation.txt) contains:
|
||||
|
||||
- RED on the original adapter: a successful resume returns no descriptor instead of a running row.
|
||||
- RED on the intermediate implementation with old file-only deduplication: the next resumed row gets no transcript.
|
||||
- Final **21/21** tests passing, including consecutive resumes with a real shared temporary JSONL, polling, notice/replay routing, late old-notice isolation, `bg_wait`, failed/management calls, missing launch identity, and the task alias. Existing spawn fixtures still pass.
|
||||
- Full `npm run format`, `npm run typecheck`, `npm run lint`, and `git diff --check` passing after the base update.
|
||||
|
||||
Only the specific adapter test file was run, not the full suite. CI has not been run. There is no old-desktop before screenshot; the reported failure and the two actual RED runs are the before evidence.
|
||||
|
||||
## Real desktop steps and observations
|
||||
|
||||
1. Built server/app dependencies and launched the existing desktop dev runner with a separate home, Electron userData, Pi configuration/session directory, and async root. The final endpoints were loopback-only: daemon 6768, Metro 8082, and CDP 9223. The renderer's desktop bridge confirmed its own managed daemon/home/server identity. Only the existing pi-subagents extension and a marker-only test agent were enabled; child tasks used `sleep`/`printf`, not workspace edits.
|
||||
2. Created a Pi parent through the normal Paseo **CLI/API**, asking it to call `subagent({agent:"qa-marker", task, async:true})`. This produced A. The model first enabled the subagent tool, then performed the ordinary spawn. This step was not submitted through the composer.
|
||||
3. Used the real Electron **composer** to ask Pi for `subagent({action:"resume", id:A, message})`, then resumed B the same way to produce C. No top-level `agent`, `task`, or `async` was supplied to these resume calls. All three finished with their requested markers.
|
||||
4. Opened the provider-owned track while C ran: it showed `1 working`, the follow-up description, and `qa-marker`. Opening that row showed the reused child conversation and the current Shell call. Each resume had a new run/async directory, while all attempts reused the same session JSONL.
|
||||
5. While C was still running, clicked **Archive finished subagents**. Completed A/B rows disappeared, while C remained visible as `1 working`.
|
||||
6. Submitted a nonexistent resume ID through the composer. Pi returned an error and the parent reported “not found”; no new row or async run appeared.
|
||||
7. The first B screenshot was taken after B finished because screenshot capture waited for fonts. It is not offered as running-state proof. A supplementary real resume from A, B_OBSERVE, was captured as `1 working` and opened successfully. It also finished. The final track contained exactly the four successful runs, not a fifth phantom from the failed resume.
|
||||
|
||||
The [selected real tool arguments/results, final statuses, and four completion notices](desktop-observations.json) preserve the test UUIDs. B's `revivedFrom` is A; C's is B. This file contains selected fields from persisted Pi entries/status files, not synthesized launch details. The [dialogue DOM excerpt](dialogue-excerpt.txt) records opening a resumed child. Test-root paths are replaced with `<QA_ROOT>` and the encoded workspace directory with `<QA_WORKSPACE>`; no auth or models configuration is included.
|
||||
|
||||
### Screenshots
|
||||
|
||||
Unmodified captures, except for the explicitly described dialogue crop. They contain only isolated test prompts, test UUIDs, and app UI; no account, credentials, or user-home paths.
|
||||
|
||||
**C running in the native provider-owned track:**
|
||||
|
||||

|
||||
|
||||
**After archiving finished rows, C remains running:**
|
||||
|
||||

|
||||
|
||||
**Opened C conversation, current Shell call and parent link:** this is an unaltered crop of the bottom 880×245 pixels of the real child-pane capture, omitting the long temporary session path above it. No state was redrawn.
|
||||
|
||||

|
||||
|
||||
**Final four completed rows, with the failed resume reported above:**
|
||||
|
||||

|
||||
|
||||
## Coverage and limits
|
||||
|
||||
| Surface | Tested |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------- |
|
||||
| macOS Electron development desktop | Yes — real renderer, composer, track, child pane, archive-finished action |
|
||||
| Pi RPC / Paseo WebSocket / daemon | Yes — real provider calls and persisted tool results/notices |
|
||||
| Standalone browser web | No |
|
||||
| iOS / Android | No |
|
||||
| Windows / Linux desktop | No |
|
||||
| Packaged release / CI | No |
|
||||
|
||||
Each resumed attempt intentionally gets its own row and reads the whole reused child transcript, including earlier history; it does not slice history by attempt. Old-notice isolation is also covered by the automated regression and the earlier real-notice host replay, rather than forcing a delayed notification in the desktop run.
|
||||
|
||||
Short-lived duplicate Shell text was observed in the streaming child pane; its cause was not established and it is not claimed fixed here. The earlier direct-SDK smoke's turn-end persistence error did not recur through this normal RPC run. All four test runners exited, the parent settled idle, and the temporary workspace stayed empty. No claims are made about old workflow issues or daemon-restart global adoption.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"capture": "Selected fields from actual Pi session entries and final status files; omitted unrelated metadata. Test UUIDs unchanged; all test-root absolute paths replaced with <QA_ROOT>.",
|
||||
"calls": [
|
||||
{
|
||||
"timestamp": "2026-11-10T04:08:31.691Z",
|
||||
"toolCallId": "call_dab88a12b4db419586ac9d0eab565ca8|fc_0e57afa06f701795016af298be50c487d0844c674c8a61fa94",
|
||||
"arguments": {
|
||||
"agent": "qa-marker",
|
||||
"task": "Run sleep 18 then printf PASEO_DESKTOP_A; do not read or write any files",
|
||||
"async": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:08:32.010Z",
|
||||
"toolCallId": "call_dab88a12b4db419586ac9d0eab565ca8|fc_0e57afa06f701795016af298be50c487d0844c674c8a61fa94",
|
||||
"isError": false,
|
||||
"details": {
|
||||
"mode": "single",
|
||||
"runId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"asyncId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"results": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:10:22.901Z",
|
||||
"toolCallId": "call_b636f1714b83481ba5b4ab0aee32b239|fc_0e57afa06f701795016af2992d215087d099231229062fa446",
|
||||
"arguments": {
|
||||
"action": "resume",
|
||||
"id": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"message": "Run sleep 18 then printf PASEO_DESKTOP_B; do not read or write files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:10:23.219Z",
|
||||
"toolCallId": "call_b636f1714b83481ba5b4ab0aee32b239|fc_0e57afa06f701795016af2992d215087d099231229062fa446",
|
||||
"isError": false,
|
||||
"details": {
|
||||
"mode": "single",
|
||||
"runId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"asyncId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"results": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:13:36.923Z",
|
||||
"toolCallId": "call_8e4ef481e0964e25bbdad4defe620e12|fc_0e57afa06f701795016af299ef47c887d0ac84e70bd50ffc5a",
|
||||
"arguments": {
|
||||
"action": "resume",
|
||||
"id": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"message": "Run bash sleep 45 then printf PASEO_DESKTOP_C; do not read or write files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:13:37.597Z",
|
||||
"toolCallId": "call_8e4ef481e0964e25bbdad4defe620e12|fc_0e57afa06f701795016af299ef47c887d0ac84e70bd50ffc5a",
|
||||
"isError": false,
|
||||
"details": {
|
||||
"mode": "single",
|
||||
"runId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||
"asyncId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||
"results": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:16:53.099Z",
|
||||
"toolCallId": "call_60e8fa68de0b436fad2aaf8cc391dae2|fc_0e57afa06f701795016af29ab2b1b487d09020677eaaf9086e",
|
||||
"arguments": {
|
||||
"action": "resume",
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"message": "Output PASEO_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:16:53.109Z",
|
||||
"toolCallId": "call_60e8fa68de0b436fad2aaf8cc391dae2|fc_0e57afa06f701795016af29ab2b1b487d09020677eaaf9086e",
|
||||
"isError": true,
|
||||
"details": {},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Async run not found for '00000000-0000-0000-0000-000000000000'."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:17:07.330Z",
|
||||
"toolCallId": "call_e6eb985f16b24308be82922d6a1d7a57|fc_0e57afa06f701795016af29ac17d5c87d0b79c8763512f43da",
|
||||
"arguments": {
|
||||
"action": "resume",
|
||||
"id": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"message": "Run bash sleep 45 then printf PASEO_DESKTOP_B_OBSERVE; do not read or write files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:17:07.668Z",
|
||||
"toolCallId": "call_e6eb985f16b24308be82922d6a1d7a57|fc_0e57afa06f701795016af29ac17d5c87d0b79c8763512f43da",
|
||||
"isError": false,
|
||||
"details": {
|
||||
"mode": "single",
|
||||
"runId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||
"asyncId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||
"results": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"finalStatuses": [
|
||||
{
|
||||
"runId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||
"state": "complete",
|
||||
"revivedFrom": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||
},
|
||||
{
|
||||
"runId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"state": "complete",
|
||||
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||
},
|
||||
{
|
||||
"runId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"state": "complete",
|
||||
"revivedFrom": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||
},
|
||||
{
|
||||
"runId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||
"state": "complete",
|
||||
"revivedFrom": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||
}
|
||||
],
|
||||
"completionNotices": [
|
||||
{
|
||||
"timestamp": "2026-11-10T04:09:07.245Z",
|
||||
"customType": "subagent-notify",
|
||||
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_A\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:10:53.423Z",
|
||||
"customType": "subagent-notify",
|
||||
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_B\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:14:37.834Z",
|
||||
"customType": "subagent-notify",
|
||||
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_C\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:17:37.887Z",
|
||||
"customType": "subagent-notify",
|
||||
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_B_OBSERVE\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
Excerpt of actual Electron renderer DOM captured after opening the resumed child.
|
||||
Test-root paths replaced with <QA_ROOT>; unrelated earlier history omitted.
|
||||
|
||||
Task: You are reviving a previous subagent conversation.
|
||||
|
||||
Original run: 832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122
|
||||
Original agent: qa-marker
|
||||
Original session file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl
|
||||
|
||||
Use the stored session context as background. Answer the orchestrator's follow-up below. Do not assume the original child session is still running.
|
||||
|
||||
Follow-up:
|
||||
Run bash sleep 45 then printf PASEO_DESKTOP_B_OBSERVE; do not read or write files
|
||||
Show more
|
||||
12:17 PM
|
||||
QA #9200 — Pi resume A→B→C
|
||||
Open parent
|
||||
@@ -0,0 +1,196 @@
|
||||
Captured command output. Only private absolute paths are replaced with <REPO_ROOT>/<USER_HOME>.
|
||||
RED runs are intentional regression failures, not final failures.
|
||||
|
||||
=== Before fix: resume arguments rejected (expected RED, exit 1) ===
|
||||
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
|
||||
|
||||
RUN v4.1.6 <REPO_ROOT>/packages/server
|
||||
|
||||
stdout | src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
|
||||
[dotenv@17.3.1] injecting env (0) from .env.test -- tip: ⚙️ suppress all logs with { quiet: true }
|
||||
[dotenv@17.3.1] injecting env (0) from ../.env -- tip: ⚙️ suppress all logs with { quiet: true }
|
||||
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts (10 tests | 1 failed) 7ms
|
||||
× registers a successful resume as a new async attempt without spawn arguments 6ms
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts > pi-subagents adapter > registers a successful resume as a new async attempt without spawn arguments
|
||||
AssertionError: expected undefined to deeply equal [ { type: 'upsert', …(5) } ]
|
||||
|
||||
- Expected:
|
||||
[
|
||||
{
|
||||
"description": "Continue",
|
||||
"id": "resume-b",
|
||||
"status": "running",
|
||||
"title": "Subagent",
|
||||
"toolCallId": "resume-b",
|
||||
"type": "upsert",
|
||||
},
|
||||
]
|
||||
|
||||
+ Received:
|
||||
undefined
|
||||
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:66:32
|
||||
64| },
|
||||
65| });
|
||||
66| expect(resumed?.subagents).toEqual([
|
||||
| ^
|
||||
67| {
|
||||
68| type: "upsert",
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
|
||||
|
||||
|
||||
Test Files 1 failed (1)
|
||||
Tests 1 failed (10)
|
||||
Start at 21:05:21
|
||||
Duration 474ms (transform 245ms, setup 31ms, import 328ms, tests 7ms, environment 0ms)
|
||||
|
||||
|
||||
=== Intermediate implementation: old file-only transcript deduplication (expected RED, exit 1) ===
|
||||
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
|
||||
|
||||
RUN v4.1.6 <REPO_ROOT>/packages/server
|
||||
|
||||
stdout | src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
|
||||
[dotenv@17.3.1] injecting env (0) from .env.test -- tip: ⚡️ secrets for agents: https://dotenvx.com/as2
|
||||
[dotenv@17.3.1] injecting env (0) from ../.env -- tip: ⚙️ override existing env vars with { override: true }
|
||||
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts (16 tests | 1 failed) 1329ms
|
||||
× follows consecutive resumed attempts and keeps stale notices on their own rows 1320ms
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts > pi-subagents adapter > follows consecutive resumed attempts and keeps stale notices on their own rows
|
||||
AssertionError: expected false to be true // Object.is equality
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
- true
|
||||
+ false
|
||||
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:155:78
|
||||
153| ]);
|
||||
154| replay.mapToolCall(call);
|
||||
155| await expect.poll(() => hasChildTimeline(received, `resume-${r…
|
||||
| ^
|
||||
156| }
|
||||
157| const oldNotice = {
|
||||
|
||||
Caused by: Error: Matcher did not succeed in time.
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:155:9
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
|
||||
|
||||
|
||||
Test Files 1 failed (1)
|
||||
Tests 1 failed | 6 passed (16)
|
||||
Start at 21:09:04
|
||||
Duration 1.85s (transform 247ms, setup 24ms, import 389ms, tests 1.33s, environment 0ms)
|
||||
|
||||
|
||||
=== Final baseline d46ee655: GREEN, exit 0 ===
|
||||
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
|
||||
|
||||
RUN v4.1.6 <REPO_ROOT>/packages/server
|
||||
|
||||
|
||||
Test Files 1 passed (1)
|
||||
Tests 21 passed (21)
|
||||
Start at 13:07:04
|
||||
Duration 1.97s (transform 256ms, setup 26ms, import 355ms, tests 1.45s, environment 0ms)
|
||||
|
||||
|
||||
=== Final typecheck, exit 0 ===
|
||||
$ npm run typecheck
|
||||
|
||||
> paseo@0.15.4 typecheck
|
||||
> npm run typecheck --workspaces --if-present
|
||||
|
||||
|
||||
> @getpaseo/builtin-plugins@0.15.4 typecheck
|
||||
> tsgo -p tsconfig.json --noEmit
|
||||
|
||||
|
||||
> @getpaseo/expo-two-way-audio@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/highlight@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/plugin@0.15.4 typecheck
|
||||
> tsgo --noEmit && npm run typecheck:examples
|
||||
|
||||
|
||||
> @getpaseo/plugin@0.15.4 typecheck:examples
|
||||
> npm --prefix ../../plugin-examples/markdown-panel install --ignore-scripts --no-audit --no-fund && tsgo -p tsconfig.examples.json --noEmit
|
||||
|
||||
|
||||
up to date in 433ms
|
||||
|
||||
> @getpaseo/protocol@0.15.4 pretypecheck
|
||||
> npm run generate:validators
|
||||
|
||||
|
||||
> @getpaseo/protocol@0.15.4 generate:validators
|
||||
> node scripts/generate-validation-aot.mjs
|
||||
|
||||
generated src/generated/validation/ws-outbound.aot.ts: envelope + 250 session message validators
|
||||
|
||||
> @getpaseo/protocol@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/client@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/server@0.15.4 typecheck
|
||||
> tsgo -p tsconfig.server.typecheck.json --noEmit
|
||||
|
||||
|
||||
> @getpaseo/app@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/relay@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/website@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/desktop@0.15.4 typecheck
|
||||
> tsgo --noEmit -p tsconfig.json
|
||||
|
||||
|
||||
> @getpaseo/cli@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
=== Final lint, exit 0 ===
|
||||
$ npm run lint
|
||||
|
||||
> paseo@0.15.4 lint
|
||||
> oxlint
|
||||
|
||||
Found 0 warnings and 0 errors.
|
||||
Finished in 2.3s on 5442 files with 177 rules using 12 threads.
|
||||
|
||||
=== Final full formatter, exit 0; no unrelated changes ===
|
||||
$ npm run format
|
||||
|
||||
> paseo@0.15.4 format
|
||||
> oxfmt .
|
||||
|
||||
Finished in 2684ms on 5928 files using 12 threads.
|
||||
|
||||
$ git diff --check
|
||||
(exit 0; no output)
|
||||
+2
-1
@@ -248,6 +248,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
|
||||
- The user-facing beta instructions, including the public TestFlight join link for the `Paseo Beta` group, live in `public-docs/updates.md` and its `zh-CN` pair. Update them when a beta path is added or removed.
|
||||
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
@@ -488,7 +489,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
|
||||
- The website download page defaults to GitHub's latest published **stable** release.
|
||||
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
|
||||
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
|
||||
- Homebrew, the Play Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. The iOS row swaps the App Store pill for the public TestFlight link (`testFlightBetaUrl` in `packages/website/src/downloads.tsx`). When another surface gains a beta path, add its row back in `packages/website/src/routes/{-$locale}/download.tsx`.
|
||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
|
||||
+1
-1
@@ -346,7 +346,7 @@ Gotchas:
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
|
||||
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Settings retains its screen and form; `AppearanceText` refreshes numeric text styles at the leaf, and its font fields use `withUnistyles` to refresh input styles without replacing the inputs. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
|
||||
- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-vG7v6uq2ADtOILEuRq9q3wTDUENAhRBbzI7UGBsWOqo=
|
||||
sha256-e6BNeOJvy1uyDe/XW9hdr7NBgJ+t9tlNum5oLNefREI=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37468,7 +37468,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39637,13 +39637,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/server": "0.16.0-beta.1",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@getpaseo/server": "0.16.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -39890,11 +39890,11 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/relay": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@getpaseo/relay": "0.16.0-beta.2",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -39906,7 +39906,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40149,7 +40149,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40195,7 +40195,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -40225,7 +40225,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
@@ -40233,8 +40233,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40244,8 +40244,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40267,7 +40267,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
@@ -40295,7 +40295,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
@@ -40517,17 +40517,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/highlight": "0.16.0-beta.1",
|
||||
"@getpaseo/plugin": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/relay": "0.16.0-beta.1",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/highlight": "0.16.0-beta.2",
|
||||
"@getpaseo/plugin": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@getpaseo/relay": "0.16.0-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41474,7 +41474,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43084,7 +43084,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -37,7 +37,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 213)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("light-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
@@ -64,7 +64,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(17, 17, 17)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(29, 29, 29)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("pure-black-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
|
||||
@@ -644,6 +644,22 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Crossing into part of the block keeps the selected code lines as a fenced block.
|
||||
await selectAssistantAcrossCodeLines(page, "not a generated link.", "return");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```',
|
||||
);
|
||||
|
||||
// So does starting partway through the block and leaving it.
|
||||
await selectAssistantAcrossCodeLines(page, "answer", "After code.");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Selecting a whole line runs off its end into the newline node. A trailing
|
||||
// newline auto-executes the line when it is pasted into a terminal.
|
||||
await selectAssistantAcrossCodeLines(page, "const", " ");
|
||||
@@ -729,13 +745,25 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
|
||||
test("a mouse drag selects across assistant list, quote, and code blocks", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-drag-",
|
||||
title: "Assistant selection drag",
|
||||
initialPrompt: "Render three paragraphs.",
|
||||
initialPrompt: "Render structured text.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
|
||||
mockAssistantResponse: [
|
||||
"First paragraph.",
|
||||
"",
|
||||
"- List item.",
|
||||
"",
|
||||
"> Quote text.",
|
||||
"",
|
||||
"```ts",
|
||||
"const answer = 1;",
|
||||
"```",
|
||||
"",
|
||||
"Third paragraph.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -760,6 +788,9 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
|
||||
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||
expect(selected).toContain("First paragraph.");
|
||||
expect(selected).toContain("List item.");
|
||||
expect(selected).toContain("Quote text.");
|
||||
expect(selected).toContain("const answer = 1;");
|
||||
expect(selected).toContain("Third paragraph.");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -2297,13 +2297,9 @@ async function changeCodeTypographyFromSettings(
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
|
||||
await page.getByRole("button", { name: "Appearance" }).click();
|
||||
// A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts
|
||||
// from the stored size. Edit the size only in the remounted form, or the remount drops it.
|
||||
const sizeField = page.getByLabel("Code font size");
|
||||
await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", ""));
|
||||
await page.getByLabel("Code font family").fill(typography.fontFamily);
|
||||
await page.getByLabel("Code font family").press("Enter");
|
||||
await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
|
||||
await sizeField.fill(String(typography.fontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedSubagent } from "../support/helpers/subagents";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { expectWorkspaceHeaderVisible, submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||
import {
|
||||
@@ -918,11 +918,12 @@ test.describe("touch composer tracks", () => {
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
await page.setViewportSize({ width: 200, height: 844 });
|
||||
const narrowWidths = await Promise.all(
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
for (const [index, width] of wideWidths.entries())
|
||||
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
|
||||
// The compact layout mounts a new composer, so measure only after it replaces the wide one.
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
for (const [index, pill] of [tasks, subagents, changes].entries())
|
||||
await expect
|
||||
.poll(async () => (await pill.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(wideWidths[index] - 1);
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
|
||||
await track.evaluate((node) => node.clientWidth),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
selectComposerDraftRange,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
|
||||
// The popover has no semantic role; its rows are React Native Pressables.
|
||||
function fileSuggestions(page: Page) {
|
||||
@@ -32,31 +33,33 @@ async function sendEmailIntact(page: Page, email: string): Promise<void> {
|
||||
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
for (const email of [
|
||||
"user <12345+user@noreply.example.com>",
|
||||
"alice@example.com",
|
||||
"noreply+@example.com",
|
||||
]) {
|
||||
test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-email-",
|
||||
title: "Email mention regression",
|
||||
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await test.step("complete a workspace file mention with Enter", async () => {
|
||||
await selectReadmeMention(page);
|
||||
});
|
||||
await test.step("send an email without offering or inserting workspace files", async () => {
|
||||
await sendEmailIntact(page, email);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
test("Enter sends email formats intact and still completes file mentions", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-email-",
|
||||
title: "Email mention regression",
|
||||
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
|
||||
});
|
||||
}
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
for (const email of [
|
||||
"user <12345+user@noreply.example.com>",
|
||||
"alice@example.com",
|
||||
"noreply+@example.com",
|
||||
]) {
|
||||
await test.step(`complete a file mention, then send ${email} intact`, async () => {
|
||||
await selectReadmeMention(page);
|
||||
await sendEmailIntact(page, email);
|
||||
// Start the next draft only after the daemon and visible agent have settled.
|
||||
const reply = await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
expect(reply.status).toBe("idle");
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
|
||||
page,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fillComposerDraft,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
function userMessages(page: Page) {
|
||||
@@ -29,7 +30,9 @@ async function pressInComposer(page: Page, key: string) {
|
||||
}
|
||||
|
||||
test.describe("Composer recall", () => {
|
||||
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
|
||||
test("recalls, edits, and sends messages while keeping slash autocomplete usable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-",
|
||||
title: "Composer recall",
|
||||
@@ -37,49 +40,44 @@ test.describe("Composer recall", () => {
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
await test.step("walk sent history, edit a recalled draft, and send it", async () => {
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
});
|
||||
await test.step("recall a slash message without opening its picker, then edit it", async () => {
|
||||
await expectComposerDraft(page, "");
|
||||
const reply = await session.client.waitForFinish(session.agentId, 15_000);
|
||||
expect(reply.status).toBe("idle");
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 4);
|
||||
|
||||
test("a recalled slash message does not open the command picker", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-slash-",
|
||||
title: "Composer recall slash",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 1);
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
|
||||
@@ -127,8 +127,42 @@ for (const scenario of [
|
||||
await creation.submitPrompt("Retry this workspace and agent together.");
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test(`an edited prompt after failed agent initialization starts the agent in its workspace${scenario.suffix}`, async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Start the original task.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
await startup.fail();
|
||||
await creation.expectStartupFailure();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await scenario.prepare(creation);
|
||||
await creation.submitPrompt("Start the edited task.");
|
||||
await creation.expectPromptVisible("Start the edited task.");
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("an edited agent draft starts after its first start failed", async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
await startup.fail();
|
||||
await creation.submitPrompt("Start the original task.");
|
||||
await creation.expectStartupFailure();
|
||||
await creation.submitPrompt("Start the edited task.");
|
||||
await creation.expectPromptVisible("Start the edited task.");
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const entry of [
|
||||
{
|
||||
name: "agent draft",
|
||||
@@ -159,7 +193,9 @@ for (const entry of [
|
||||
await entry.settled(creation);
|
||||
await creation.expectCreatedAgentError();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("created-agent-prompt-error.png"),
|
||||
});
|
||||
await creation.reloadAgent();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
|
||||
|
||||
@@ -5,6 +5,10 @@ import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { contrastRatio } from "../../src/utils/color";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
const PLUGIN_ID = "plugin-theme-e2e";
|
||||
|
||||
@@ -40,6 +44,16 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||
ring: "#9ca0b0",
|
||||
},
|
||||
});
|
||||
for (const [id, name, overrides] of [
|
||||
["solarized-dark", "Solarized Dark", {}],
|
||||
["solarized-dark-custom", "Solarized Dark Custom", { sidebarSelected: "#334455", foregroundExtraMuted: "#ddeeff" }],
|
||||
]) {
|
||||
plugin.addTheme({ id, name, appearance: "dark", colors: {
|
||||
background: "#002b36", foreground: "#839496", raised: "#073642",
|
||||
control: "#073642", border: "#073642", accent: "#268bd2",
|
||||
mutedForeground: "#586e75", ring: "#93a1a1", ...overrides,
|
||||
}});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
@@ -47,6 +61,88 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||
// contributed palette reached the semantic tokens rather than just the swatch.
|
||||
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
|
||||
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
|
||||
const LATTE_RING = "rgb(156, 160, 176)";
|
||||
|
||||
async function selectTheme(page: Page, name: string) {
|
||||
await page.goto("/settings");
|
||||
await openSettingsSection(page, "appearance");
|
||||
const theme = page.getByRole("radio", { name, exact: true });
|
||||
await expect(theme).toBeVisible({ timeout: 30_000 });
|
||||
await theme.click();
|
||||
await expect(theme).toBeChecked();
|
||||
}
|
||||
|
||||
async function expectTimestampContrast(row: Locator) {
|
||||
const timestamp = row.getByTestId("sidebar-workspace-timestamp");
|
||||
await expect(timestamp).toBeVisible();
|
||||
const hex = (rgb: string) =>
|
||||
`#${rgb
|
||||
.match(/\d+/g)!
|
||||
.slice(0, 3)
|
||||
.map((value) => Number(value).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
const background = await row.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
const foreground = await timestamp.evaluate((element) => getComputedStyle(element).color);
|
||||
expect(contrastRatio(hex(background), hex(foreground))).toBeGreaterThanOrEqual(2.5);
|
||||
}
|
||||
|
||||
test("keeps plugin workspace timestamps readable on state surfaces and honors overrides", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-state-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
const selected = await seedMockAgentWorkspace({
|
||||
repoPrefix: "theme-selected-",
|
||||
title: "Selected theme workspace",
|
||||
});
|
||||
const hovered = await seedMockAgentWorkspace({
|
||||
repoPrefix: "theme-hovered-",
|
||||
title: "Hovered theme workspace",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await selectTheme(page, "Solarized Dark");
|
||||
await openAgentRoute(page, selected);
|
||||
const selectedRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${selected.workspaceId}`,
|
||||
);
|
||||
const hoveredRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${hovered.workspaceId}`,
|
||||
);
|
||||
await expect(selectedRow).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(selectedRow).toHaveCSS("background-color", "rgb(33, 58, 66)");
|
||||
await expectTimestampContrast(selectedRow);
|
||||
await hoveredRow.hover();
|
||||
await expect(hoveredRow).toHaveCSS("background-color", "rgb(19, 50, 60)");
|
||||
await expectTimestampContrast(hoveredRow);
|
||||
|
||||
await selectTheme(page, "Solarized Dark Custom");
|
||||
await openAgentRoute(page, selected);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(selectedRow).toHaveCSS("background-color", "rgb(51, 68, 85)");
|
||||
await expect(selectedRow.getByTestId("sidebar-workspace-timestamp")).toHaveCSS(
|
||||
"color",
|
||||
"rgb(221, 238, 255)",
|
||||
);
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close();
|
||||
await selected.cleanup();
|
||||
await hovered.cleanup();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("applies a contributed theme and falls back when its plugin is gone", async ({
|
||||
page,
|
||||
@@ -83,6 +179,13 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
|
||||
});
|
||||
|
||||
await test.step("keyboard focus draws the outline in the theme's ring color", async () => {
|
||||
await page.getByRole("radio", { name: "Catppuccin Latte", exact: true }).click();
|
||||
await page.mouse.move(0, 0);
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator(":focus-visible").first()).toHaveCSS("outline-color", LATTE_RING);
|
||||
});
|
||||
|
||||
await mochaItem.click();
|
||||
await expect(mochaItem).toBeChecked();
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
closeSidebarDisplayPreferences,
|
||||
openMobileAgentSidebar,
|
||||
openSidebarDisplayPage,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "row-badges-e2e";
|
||||
const TITLE = "Badge host workspace";
|
||||
|
||||
const PLUGIN_SOURCE = `import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function Review() {
|
||||
return <WorkspaceRowBadge icon="Eye" label="In review" tone="warning" />;
|
||||
}
|
||||
|
||||
function Docs() {
|
||||
return <WorkspaceRowBadge icon="BookOpen" label="Docs" />;
|
||||
}
|
||||
|
||||
function IconOnly() {
|
||||
return <WorkspaceRowBadge icon="Star" />;
|
||||
}
|
||||
|
||||
function EmptyLabel() {
|
||||
return <WorkspaceRowBadge icon="Circle" label="" />;
|
||||
}
|
||||
|
||||
function Quiet() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function Broken() {
|
||||
throw new Error("badge failed");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addWorkspaceRowBadgeItem({ id: "review", title: "Review state", icon: "Eye", Component: Review });
|
||||
client.addWorkspaceRowBadgeItem({ id: "docs", title: "Docs state", icon: "BookOpen", Component: Docs });
|
||||
client.addWorkspaceRowBadgeItem({ id: "icon", title: "Icon state", icon: "Star", Component: IconOnly });
|
||||
client.addWorkspaceRowBadgeItem({ id: "empty", title: "Empty label state", icon: "Circle", Component: EmptyLabel });
|
||||
client.addWorkspaceRowBadgeItem({ id: "quiet", title: "Quiet state", icon: "Moon", Component: Quiet });
|
||||
client.addWorkspaceRowBadgeItem({ id: "broken", title: "Broken state", icon: "Bug", Component: Broken });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
// The meta line the badges draw on. Each item sits in one wrapper of its own, so the line is two up.
|
||||
const LABELS = { review: "In review", docs: "Docs" } as const;
|
||||
|
||||
function badgeLine(page: Page, itemId: keyof typeof LABELS) {
|
||||
return badge(page, itemId).locator("xpath=../..");
|
||||
}
|
||||
|
||||
function badge(page: Page, itemId: keyof typeof LABELS) {
|
||||
return page.getByLabel(LABELS[itemId], { exact: true }).locator("visible=true");
|
||||
}
|
||||
|
||||
async function toggleShowItem(page: Page, label: string, touch: boolean) {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: label, exact: true }).click();
|
||||
if (touch) {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
} else {
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
}
|
||||
}
|
||||
|
||||
test("a plugin badge shows on workspace rows, hides quietly, and follows its Show toggle", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "row-badges-", title: TITLE });
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-row-badges-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the badge draws on the row; a null item and a throwing item draw nothing", async () => {
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await expect(badge(page, "review")).toContainText("In review");
|
||||
// The null and throwing items after them draw nothing, and the line adds nothing of its own.
|
||||
await expect(badgeLine(page, "review")).toHaveText("In reviewDocs");
|
||||
// An icon-only badge is named by its item's title.
|
||||
await expect(
|
||||
page.getByLabel("Icon state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel("Empty label state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(TITLE).first()).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("wide.png") });
|
||||
});
|
||||
|
||||
await test.step("every item has a Show toggle, and switching one off removes only that badge", async () => {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
for (const label of ["Review state", "Quiet state", "Broken state", "Docs state"]) {
|
||||
await expect(page.getByRole("menuitem", { name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByTestId("sidebar-plugin-row-badge-items-separator")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("show-menu.png") });
|
||||
await page.getByRole("menuitem", { name: "Review state", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
// The first badge is gone, so the next one leads the line without a "·".
|
||||
await expect(badgeLine(page, "docs")).toHaveText("Docs");
|
||||
await toggleShowItem(page, "Review state", false);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the badge draws on a compact layout", async () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("compact.png") });
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -14,12 +14,17 @@ import {
|
||||
revealSettingsSidebarSection,
|
||||
expectSettingsSidebarScrollSettled,
|
||||
scrollSettingsSidebarUp,
|
||||
changeSettingsInterfaceFontSize,
|
||||
selectSettingsHostSectionsRapidly,
|
||||
expectSettingsSidebarScrollOffset,
|
||||
scrollSettingsDetailDown,
|
||||
expectSettingsDetailScrollOffset,
|
||||
openSettingsSection,
|
||||
changeSettingsSyntaxTheme,
|
||||
revealSettingsSyntaxTheme,
|
||||
expectSettingsSyntaxColorChanged,
|
||||
changeSettingsTypography,
|
||||
normalizeSettingsFontSizes,
|
||||
changeSettingsInterfaceFontSizeKeepingSidebarPosition,
|
||||
expectSettingsHeader,
|
||||
openAddHostFlow,
|
||||
selectHostConnectionType,
|
||||
@@ -84,13 +89,32 @@ test.describe("Settings sidebar scroll position", () => {
|
||||
await expectSettingsSidebarScrollOffset(page, 0);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position through interface font changes", async ({ page }) => {
|
||||
test("updates appearance without losing the detail scroll or form edits", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
const offset = await scrollSettingsSidebarToEnd(page);
|
||||
await changeSettingsInterfaceFontSize(page, 20);
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
const { offset, keywordColor } = await revealSettingsSyntaxTheme(page);
|
||||
await changeSettingsSyntaxTheme(page, "Dracula");
|
||||
await expectSettingsDetailScrollOffset(page, offset);
|
||||
await expectSettingsSyntaxColorChanged(page, keywordColor);
|
||||
await changeSettingsTypography(page);
|
||||
});
|
||||
|
||||
test("shows normalized font sizes after committing out-of-range and empty values", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await normalizeSettingsFontSizes(page);
|
||||
});
|
||||
|
||||
test("keeps the sidebar context through interface font changes", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await scrollSettingsSidebarToEnd(page);
|
||||
const offset = await changeSettingsInterfaceFontSizeKeepingSidebarPosition(page, 20);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "../support/helpers/usage-login";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
closeUsage,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
@@ -79,12 +80,17 @@ function twoHoursAgo(): string {
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
async function qaScreenshot(page: Page, name: string) {
|
||||
async function qaScreenshot(page: Page, name: string, target: "page" | "summary" = "page") {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
const file = path.join(directory, `${name}.png`);
|
||||
if (target === "summary") {
|
||||
await usageItem(page).screenshot({ path: file });
|
||||
return;
|
||||
}
|
||||
await page.screenshot({ path: file });
|
||||
}
|
||||
|
||||
function hostFilter(page: Page) {
|
||||
@@ -628,6 +634,15 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toBeVisible();
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await qaScreenshot(page, `usage-error-detail-${compact ? "compact" : "desktop"}`);
|
||||
await closeUsage(page);
|
||||
await qaScreenshot(page, `usage-error-summary-${compact ? "compact" : "desktop"}`, "summary");
|
||||
await openUsageFromItem(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
@@ -639,6 +654,8 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: No active quota");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
@@ -647,6 +664,9 @@ for (const compact of [false, true]) {
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43% used");
|
||||
await expectPinnedUsage(page, ["43% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
await expect(usageItem(page)).not.toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
// The browser owns Cmd+Shift+brackets for its own tabs, so web keeps
|
||||
// Alt+Shift+brackets on every OS. Desktop bindings live in the desktop suite.
|
||||
test("web switches to the previous and next tab with Alt+Shift+[ and ]", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "tab-relative-web-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const terminalTabs = page
|
||||
.locator('[data-testid^="workspace-tab-terminal_"]')
|
||||
.filter({ visible: true });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs).toHaveCount(2, { timeout: 30_000 });
|
||||
const first = terminalTabs.nth(0);
|
||||
const second = terminalTabs.nth(1);
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Alt+Shift+BracketLeft");
|
||||
await expect(first).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Alt+Shift+BracketRight");
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
readReactRenders,
|
||||
recordReactRenders,
|
||||
resetReactRenders,
|
||||
} from "../support/helpers/react-renders";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
async function createMockAgent(workspace: SeededWorkspace, title: string): Promise<string> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
test.describe("Updates to agents in other workspaces", () => {
|
||||
let open: SeededWorkspace;
|
||||
let other: SeededWorkspace;
|
||||
|
||||
test.afterEach(async () => {
|
||||
await open?.cleanup();
|
||||
await other?.cleanup();
|
||||
});
|
||||
|
||||
test("do not re-render the open workspace screen", async ({ page }) => {
|
||||
open = await seedWorkspace({ repoPrefix: "unrelated-update-open-", title: "Open" });
|
||||
other = await seedWorkspace({ repoPrefix: "unrelated-update-other-", title: "Other" });
|
||||
const openAgentId = await createMockAgent(open, "Open agent");
|
||||
const otherAgentId = await createMockAgent(other, "Other agent");
|
||||
|
||||
await recordReactRenders(page);
|
||||
await openAgentRoute(page, { workspaceId: open.workspaceId, agentId: openAgentId });
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${openAgentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await page.waitForTimeout(2_000);
|
||||
await resetReactRenders(page);
|
||||
|
||||
await other.client.sendAgentMessage(otherAgentId, "update");
|
||||
await other.client.waitForFinish(otherAgentId, 60_000);
|
||||
// The favicon follows every agent on the host, so it renders when the other agent runs and
|
||||
// settles. That confirms the updates reached this page.
|
||||
await expect
|
||||
.poll(async () => (await readReactRenders(page)).FaviconStatusSync ?? 0)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const renders = await readReactRenders(page);
|
||||
expect(renders.WorkspaceScreenContent ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,20 @@ agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \
|
||||
--artifacts-dir /tmp/paseo-model-tablet-qa
|
||||
```
|
||||
|
||||
`model-tablet-profile.android.ad` taps the `+` of a model row in New workspace, asserts the seeded
|
||||
profile form opens, and asserts that closing the form leaves the remembered model selected. Run it
|
||||
on the same tablet setup, after `model-tablet.android.ad` or with `MODEL_LABEL` remembered, against a
|
||||
host with no agent profiles: with a profile saved, the picker opens on its root view, which has no
|
||||
Back. Set `PROFILE_MODEL_ID` to a different model on the provider's first page:
|
||||
|
||||
```sh
|
||||
agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet-profile.android.ad \
|
||||
--serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \
|
||||
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
|
||||
--env PROVIDER_ID=codex --env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" \
|
||||
--env PROFILE_MODEL_ID="$MODEL_QA_PROFILE_MODEL_ID"
|
||||
```
|
||||
|
||||
The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open;
|
||||
a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent
|
||||
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Connected host with MODEL_LABEL selected and no agent profiles, 1600x2560 at density 320. See README.md.
|
||||
# FORM_URL=paseo://new?serverId=<serverId>; PROFILE_MODEL_ID is another catalog model with no profile.
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
env PROVIDER_ID=codex
|
||||
|
||||
open "${APP_ID}" "${FORM_URL}"
|
||||
wait "label=\"Workspace project\"" 20000
|
||||
press "id=\"combined-model-selector\""
|
||||
wait "id=\"combobox-desktop-container\"" 10000
|
||||
# A remembered model opens its provider's list. Return to the provider page first.
|
||||
press "label=\"Back\""
|
||||
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
|
||||
press "id=\"model-provider-${PROVIDER_ID}\""
|
||||
wait "id=\"compact-model-list\"" 3000
|
||||
# The row's profile action sits inside Android's independent scroll viewport.
|
||||
press "id=\"model-create-profile-${PROVIDER_ID}-${PROFILE_MODEL_ID}\""
|
||||
wait "text=\"Add agent profile\"" 5000
|
||||
# Closing the form keeps the remembered model: the tap did not also select the row.
|
||||
back
|
||||
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
|
||||
close
|
||||
@@ -505,10 +505,14 @@ export async function expectCreateProfileFromModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
const action = pickerViewport(page).getByTestId(
|
||||
`model-create-profile-${input.provider}-${input.modelId}`,
|
||||
);
|
||||
// Until its row is hovered the plus is invisible, so a tap on its spot reaches the row.
|
||||
await expect(action).toHaveCSS("pointer-events", "none");
|
||||
await modelRow(page, input).hover();
|
||||
await pickerViewport(page)
|
||||
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
|
||||
.click();
|
||||
await expect(action).toHaveCSS("pointer-events", "auto");
|
||||
await action.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
|
||||
|
||||
@@ -142,6 +142,8 @@ export async function createCreationScenario(page: Page) {
|
||||
const requests = await observeCreationRequests(page);
|
||||
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
|
||||
let workspaceId = project.workspaceId;
|
||||
// Workspaces the scenario opens itself to evict the deck; they are not creations under test.
|
||||
const evictionWorkspaceIds = new Set<string>();
|
||||
return {
|
||||
cleanup: project.cleanup,
|
||||
async openWorkspaceForm(isolation: "local" | "worktree") {
|
||||
@@ -232,7 +234,9 @@ export async function createCreationScenario(page: Page) {
|
||||
await requests.settled();
|
||||
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
workspace.projectId === project.projectId &&
|
||||
workspace.id !== project.workspaceId &&
|
||||
!evictionWorkspaceIds.has(workspace.id),
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
workspaceId = workspaces[0]!.id;
|
||||
@@ -276,6 +280,7 @@ export async function createCreationScenario(page: Page) {
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
});
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
|
||||
evictionWorkspaceIds.add(result.workspace.id);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
|
||||
@@ -106,6 +106,33 @@ export async function changeSettingsInterfaceFontSize(page: Page, size: number):
|
||||
await input.fill(String(size));
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(String(size));
|
||||
await expect(
|
||||
page.getByRole("navigation", { name: "Settings" }).getByText("Appearance", { exact: true }),
|
||||
).toHaveCSS("font-size", `${size}px`);
|
||||
}
|
||||
|
||||
export async function changeSettingsInterfaceFontSizeKeepingSidebarPosition(
|
||||
page: Page,
|
||||
size: number,
|
||||
): Promise<number> {
|
||||
const navigation = page.getByRole("navigation", { name: "Settings" });
|
||||
const anchor = navigation.getByRole("button", { name: "About", exact: true });
|
||||
const before = await anchor.evaluate((node) => ({
|
||||
top: node.getBoundingClientRect().top,
|
||||
height: node.getBoundingClientRect().height,
|
||||
}));
|
||||
await changeSettingsInterfaceFontSize(page, size);
|
||||
// Larger fonts reflow rows. Preserve the visible destination, rather than its old pixel offset.
|
||||
await expect
|
||||
.poll(() =>
|
||||
anchor.evaluate((node, top) => Math.abs(node.getBoundingClientRect().top - top), before.top),
|
||||
)
|
||||
.toBeLessThan(before.height);
|
||||
const offset = await page
|
||||
.getByTestId("settings-sidebar-scroll-body")
|
||||
.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return offset;
|
||||
}
|
||||
|
||||
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
|
||||
@@ -613,3 +640,100 @@ export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
|
||||
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function revealSettingsSyntaxTheme(page: Page) {
|
||||
await page.getByLabel(/Highlight theme:/).scrollIntoViewIfNeeded();
|
||||
const offset = await page
|
||||
.getByTestId("settings-detail-scroll-body")
|
||||
.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
const keywordColor = await page
|
||||
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
|
||||
.getByText("export", { exact: true })
|
||||
.first()
|
||||
.evaluate((node) => getComputedStyle(node).color);
|
||||
return { offset, keywordColor };
|
||||
}
|
||||
|
||||
export async function changeSettingsSyntaxTheme(page: Page, theme: string): Promise<void> {
|
||||
await page.getByLabel(/Highlight theme:/).click();
|
||||
await page.getByRole("menuitem", { name: theme, exact: true }).click();
|
||||
await expect(page.getByLabel(`Highlight theme: ${theme}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSettingsSyntaxColorChanged(
|
||||
page: Page,
|
||||
previousColor: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
|
||||
.getByText("export", { exact: true })
|
||||
.first(),
|
||||
).not.toHaveCSS("color", previousColor);
|
||||
}
|
||||
|
||||
export async function changeSettingsTypography(page: Page): Promise<void> {
|
||||
const input = page.getByRole("textbox", { name: "Interface font size", exact: true });
|
||||
await input.fill("18");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue("18");
|
||||
await expect(input).toHaveCSS("font-size", "18px");
|
||||
await expect(page.getByText("Code size", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
const navigation = page.getByRole("navigation", { name: "Settings" });
|
||||
await expect(navigation.getByText("Appearance", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
await expect(navigation.getByText("Providers", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
await expect(page.getByText("Dracula", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
const familyInput = page.getByRole("textbox", { name: "Code font family", exact: true });
|
||||
await familyInput.fill("Courier New, Courier, monospace");
|
||||
await familyInput.press("Enter");
|
||||
const codeInput = page.getByRole("textbox", { name: "Code font size", exact: true });
|
||||
await codeInput.fill("16");
|
||||
await codeInput.press("Enter");
|
||||
await expect(codeInput).toHaveValue("16");
|
||||
const preview = page.getByRole("img", {
|
||||
name: "Live preview of content typography, syntax theme, and code font",
|
||||
});
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS("font-size", "16px");
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
|
||||
"line-height",
|
||||
"24px",
|
||||
);
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
|
||||
"font-family",
|
||||
/Courier/,
|
||||
);
|
||||
const contentInput = page.getByRole("textbox", { name: "Content font size", exact: true });
|
||||
await contentInput.fill("20");
|
||||
await contentInput.press("Enter");
|
||||
await expect(preview.getByText("Readable content and code preview", { exact: true })).toHaveCSS(
|
||||
"font-size",
|
||||
"20px",
|
||||
);
|
||||
}
|
||||
|
||||
export async function normalizeSettingsFontSizes(page: Page): Promise<void> {
|
||||
for (const [name, minimum, maximum] of [
|
||||
["Interface font size", "10", "21"],
|
||||
["Content font size", "10", "21"],
|
||||
["Code font size", "9", "22"],
|
||||
] as const) {
|
||||
const input = page.getByRole("textbox", { name, exact: true });
|
||||
await input.fill("999");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
// Clamp again when the saved value is already at the maximum.
|
||||
await input.fill("999");
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
await input.fill("");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
await input.fill("1");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(minimum);
|
||||
await input.fill("1");
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(minimum);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ android {
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName '0.1.0'
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation 'com.facebook.react:react-android'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test:runner:1.6.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity android:name="android.app.Activity" android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.view.KeyEvent
|
||||
import android.widget.EditText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Exercises the real Android window and editable that receive physical keys. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TerminalArrowTest {
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private var activity: Activity? = null
|
||||
|
||||
@After fun finish() {
|
||||
instrumentation.runOnMainSync {
|
||||
activity?.let {
|
||||
HardwareKeyboard.release(it.window)
|
||||
it.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun physicalArrowsDoNotMoveTheTerminalInputCaret() {
|
||||
withInput(terminal = true) { input, host ->
|
||||
for (code in listOf(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN)) {
|
||||
for (modifiers in listOf(0, KeyEvent.META_SHIFT_ON, KeyEvent.META_ALT_ON, KeyEvent.META_CTRL_ON)) {
|
||||
input.setSelection(3)
|
||||
for (repeat in 0..2) {
|
||||
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, code, repeat, modifiers)))
|
||||
assertEquals("Physical arrows must leave the hidden input caret at the end", 3, input.selectionStart)
|
||||
}
|
||||
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_UP, code, 0, modifiers)))
|
||||
}
|
||||
}
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
|
||||
assertEquals("abcx", input.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun physicalArrowsStillEditOtherTextFields() {
|
||||
withInput(terminal = false) { input, host ->
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT))
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_LEFT))
|
||||
assertEquals(2, input.selectionStart)
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
|
||||
assertEquals("abxc", input.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun withInput(terminal: Boolean, check: (EditText, Activity) -> Unit) {
|
||||
val host = instrumentation.startActivitySync(
|
||||
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
activity = host
|
||||
lateinit var input: EditText
|
||||
instrumentation.runOnMainSync {
|
||||
input = EditText(host)
|
||||
if (terminal) input.setTag(com.facebook.react.R.id.view_tag_native_id, "paseo-terminal-input")
|
||||
host.setContentView(input)
|
||||
input.setText("abc")
|
||||
input.requestFocus()
|
||||
input.setSelection(3)
|
||||
HardwareKeyboard.observe(host.window)
|
||||
}
|
||||
instrumentation.waitForIdleSync()
|
||||
instrumentation.runOnMainSync {
|
||||
assertSame("The editable must own window focus before keys are dispatched", input, host.window.currentFocus)
|
||||
check(input, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-5
@@ -10,18 +10,24 @@ import android.view.KeyEvent
|
||||
import android.view.Window
|
||||
import android.widget.EditText
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
import com.facebook.react.R
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.interfaces.ExtraWindowEventListener
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
// Matched against the hidden terminal input's `nativeID`
|
||||
// (TERMINAL_INPUT_NATIVE_ID in terminal-input.native.tsx).
|
||||
private const val TERMINAL_INPUT_NATIVE_ID = "paseo-terminal-input"
|
||||
|
||||
internal object HardwareKeyboard {
|
||||
var module: PaseoHardwareKeyboardModule? = null
|
||||
@Volatile var overlayKeys: Set<String> = emptySet()
|
||||
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
|
||||
val dispatcher = WindowKeyDispatcher<KeyEvent>()
|
||||
private val consumedPresses = mutableSetOf<Int>()
|
||||
private val terminalArrowCodes = setOf("ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight")
|
||||
|
||||
fun observe(window: Window) {
|
||||
if (windows.containsKey(window)) return
|
||||
@@ -38,7 +44,7 @@ internal object HardwareKeyboard {
|
||||
consumedPresses.clear()
|
||||
}
|
||||
|
||||
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
|
||||
fun dispatch(event: KeyEvent, composing: Boolean, terminalFocused: Boolean): Boolean {
|
||||
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
|
||||
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
|
||||
val code = code(event.keyCode)
|
||||
@@ -64,8 +70,12 @@ internal object HardwareKeyboard {
|
||||
))
|
||||
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
|
||||
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
|
||||
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
|
||||
return overlayOwnsKey
|
||||
// Without this the hidden input consumes the arrows as caret movement and
|
||||
// its next text change stops being a plain append. Modifiers stay
|
||||
// forwarded: Alt/Shift+arrow are terminal editing keys.
|
||||
val terminalOwnsKey = terminalFocused && !composing && code in terminalArrowCodes
|
||||
if (overlayOwnsKey || terminalOwnsKey) consumedPresses.add(event.keyCode)
|
||||
return overlayOwnsKey || terminalOwnsKey
|
||||
}
|
||||
|
||||
private fun code(keyCode: Int): String = when (keyCode) {
|
||||
@@ -107,9 +117,15 @@ internal object HardwareKeyboard {
|
||||
|
||||
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val editable = (window.currentFocus as? EditText)?.text
|
||||
val focus = window.currentFocus
|
||||
val editable = (focus as? EditText)?.text
|
||||
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
|
||||
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
|
||||
val terminalFocused = focus?.getTag(R.id.view_tag_native_id) == TERMINAL_INPUT_NATIVE_ID
|
||||
return HardwareKeyboard.dispatcher.dispatch(
|
||||
event,
|
||||
{ HardwareKeyboard.dispatch(event, composing, terminalFocused) },
|
||||
{ previous.dispatchKeyEvent(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
|
||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||
private var isHardwareSubmitEnabled = false
|
||||
private var overlayKeys: Set<String> = []
|
||||
private var claimedShortcuts: [ClaimedShortcut] = []
|
||||
|
||||
/// UIKit spells non-character keys as sentinel input strings rather than as the
|
||||
/// character they would type. JS sends the DOM `code` and the mapping lives
|
||||
@@ -19,6 +20,42 @@ private let namedKeyInputs: [String: String] = [
|
||||
"Enter": "\r",
|
||||
]
|
||||
|
||||
/// A shortcut the window takes from the focused text control: Shift+Tab would
|
||||
/// otherwise insert a tab, and Option+Shift+[ would type a quote.
|
||||
private struct ClaimedShortcut {
|
||||
let input: String
|
||||
let modifierFlags: UIKeyModifierFlags
|
||||
}
|
||||
|
||||
struct ClaimedShortcutRecord: Record {
|
||||
@Field var code: String = ""
|
||||
@Field var shift: Bool = false
|
||||
@Field var alt: Bool = false
|
||||
}
|
||||
|
||||
private let claimableKeyInputs: [String: String] = [
|
||||
"Tab": "\t", "Space": " ", "Enter": "\r", "Escape": UIKeyCommand.inputEscape,
|
||||
"ArrowUp": UIKeyCommand.inputUpArrow, "ArrowDown": UIKeyCommand.inputDownArrow,
|
||||
"ArrowLeft": UIKeyCommand.inputLeftArrow, "ArrowRight": UIKeyCommand.inputRightArrow,
|
||||
"Minus": "-", "Equal": "=", "BracketLeft": "[", "BracketRight": "]", "Backslash": "\\",
|
||||
"Semicolon": ";", "Quote": "'", "Backquote": "`", "Comma": ",", "Period": ".", "Slash": "/",
|
||||
]
|
||||
|
||||
/// UIKit matches a key command against the unmodified character, so
|
||||
/// Option+Shift+[ is registered as "[" with both modifier flags.
|
||||
private func claimedShortcut(_ record: ClaimedShortcutRecord) -> ClaimedShortcut? {
|
||||
let code = record.code
|
||||
let input: String?
|
||||
if code.count == 4 && code.hasPrefix("Key") { input = code.suffix(1).lowercased() }
|
||||
else if code.count == 6 && code.hasPrefix("Digit") { input = String(code.suffix(1)) }
|
||||
else { input = claimableKeyInputs[code] }
|
||||
guard let input else { return nil }
|
||||
var modifierFlags: UIKeyModifierFlags = []
|
||||
if record.shift { modifierFlags.insert(.shift) }
|
||||
if record.alt { modifierFlags.insert(.alternate) }
|
||||
return ClaimedShortcut(input: input, modifierFlags: modifierFlags)
|
||||
}
|
||||
|
||||
/// Whether the first responder is mid-IME-composition.
|
||||
///
|
||||
/// A committing key has to stay out of the way while a CJK candidate is being
|
||||
@@ -62,6 +99,14 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
}
|
||||
}
|
||||
|
||||
Function("setClaimedShortcuts") { (shortcuts: [ClaimedShortcutRecord]) in
|
||||
let claimed = shortcuts.compactMap(claimedShortcut)
|
||||
DispatchQueue.main.async {
|
||||
claimedShortcuts = claimed
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
}
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
|
||||
self.keyboardObservers = []
|
||||
@@ -69,6 +114,7 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
activeModule = nil
|
||||
isHardwareSubmitEnabled = false
|
||||
overlayKeys = []
|
||||
claimedShortcuts = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +185,10 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
{
|
||||
commands.append(makeSubmitCommand())
|
||||
}
|
||||
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
|
||||
if !isComposingText() {
|
||||
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
|
||||
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -172,6 +221,18 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
return command
|
||||
}
|
||||
|
||||
/// The window's press stream still emits the shortcut; the command only keeps
|
||||
/// the key away from the text control.
|
||||
private func makeClaimedCommand(_ shortcut: ClaimedShortcut) -> UIKeyCommand {
|
||||
let command = UIKeyCommand(
|
||||
input: shortcut.input,
|
||||
modifierFlags: shortcut.modifierFlags,
|
||||
action: #selector(handleHardwareKeyboardShortcut(_:))
|
||||
)
|
||||
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
|
||||
return command
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
|
||||
guard canSubmitCurrentTextInput() else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -81,12 +81,14 @@
|
||||
|
||||
/* Suppress the browser's default focus outline — it appears on mouse
|
||||
clicks and looks out of place against our themed surfaces. Keep a
|
||||
themed ring for keyboard users via :focus-visible. */
|
||||
themed ring for keyboard users via :focus-visible. The ring is the active
|
||||
theme's `ring` color, exposed by Unistyles as a CSS variable; the fallback
|
||||
only shows before the theme mounts. */
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
*:focus-visible {
|
||||
outline: 2px solid #20744a;
|
||||
outline: 2px solid var(--colors-ring, #20744a);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -864,6 +864,8 @@ const ROOT_STACK_SCREEN_OPTIONS = {
|
||||
animation: "none" as const,
|
||||
};
|
||||
const ROOT_STACK_NESTED_NAVIGATOR_SCREENS = ["h/[serverId]"] as const;
|
||||
// Settings owns reactive typography leaves; its scroll and form state survive appearance edits.
|
||||
const ROOT_STACK_SCREEN_OWNED_APPEARANCE = ["settings/[section]"] as const;
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
@@ -871,6 +873,7 @@ function RootStack() {
|
||||
<ThemedStack
|
||||
screenOptions={ROOT_STACK_SCREEN_OPTIONS}
|
||||
nestedNavigatorScreens={ROOT_STACK_NESTED_NAVIGATOR_SCREENS}
|
||||
screenOwnedAppearance={ROOT_STACK_SCREEN_OWNED_APPEARANCE}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Text } from "react-native";
|
||||
|
||||
export { Text as AppearanceText };
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Text as NativeText } from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
// Web numeric tokens are baked into classes. Subscribe this leaf so withUnistyles
|
||||
// resolves its style prop again without replacing the surrounding form or scroll view.
|
||||
function typographyChanges(theme: Theme) {
|
||||
void theme.fontSize;
|
||||
return {};
|
||||
}
|
||||
|
||||
export const AppearanceText = withUnistyles(NativeText, typographyChanges);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
|
||||
@@ -759,6 +759,56 @@ describe("assistant selection copy inside highlighted code", () => {
|
||||
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps line breaks when a quote selection ends partway through code", () => {
|
||||
const message = mountHighlighted();
|
||||
const quote = document.createElement("div");
|
||||
quote.setAttribute("data-paseo-markdown-tag", "blockquote");
|
||||
quote.textContent = "Quoted context";
|
||||
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(quote);
|
||||
|
||||
const content = copyBetween(
|
||||
[tokenText(message, "Quoted context"), 0],
|
||||
[tokenText(message, " (answer)"), 9],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"> Quoted context\n\n```typescript\nconst answer = 1;\n if (answer)\n```",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps indented code lines inside their list item", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = [
|
||||
'<div data-testid="assistant-message">',
|
||||
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
|
||||
'<div data-paseo-markdown-tag="li">',
|
||||
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
|
||||
'<div><div data-paseo-markdown-tag="p"><span>Item text</span></div>',
|
||||
highlightedCodeBlock("typescript"),
|
||||
"</div></div></div></div>",
|
||||
].join("");
|
||||
document.body.append(host);
|
||||
|
||||
const content = copyBetween([tokenText(host, "1."), 0], [tokenText(host, " (answer)"), 9]);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"1. Item text\n\n ```typescript\n const answer = 1;\n if (answer)\n ```",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps line breaks when a selection starts partway through code", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyBetween(
|
||||
[tokenText(message, " answer"), 1],
|
||||
[tokenText(message, "After the block."), "After the block.".length],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"```typescript\nanswer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("semantic library output", () => {
|
||||
|
||||
@@ -81,7 +81,8 @@ turndown.addRule("compactListItem", {
|
||||
const item = content
|
||||
.replace(/^\n+/, "")
|
||||
.replace(/\n+$/, "\n")
|
||||
.replace(/\n(?=\S)/g, "\n ");
|
||||
// Every non-empty line, so indented code lines stay inside the item.
|
||||
.replace(/\n(?=[^\n])/g, "\n ");
|
||||
const parent = node.parentElement;
|
||||
if (parent?.nodeName !== "OL") {
|
||||
return `${options.bulletListMarker} ${item}\n`;
|
||||
@@ -359,6 +360,9 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
|
||||
return true;
|
||||
}
|
||||
if (isCrossBoundaryCodeElement(range, element, tag)) {
|
||||
return true;
|
||||
}
|
||||
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
||||
if (
|
||||
(isSelectableSemantic || element.tagName === "A") &&
|
||||
@@ -386,6 +390,11 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
return hasSelectedAllContents(range, element, tag === "li");
|
||||
}
|
||||
|
||||
function isCrossBoundaryCodeElement(range: Range, element: Element, tag: string | null): boolean {
|
||||
const isCode = tag === "pre" || (tag === "code" && Boolean(element.closest(CODE_BLOCK_SELECTOR)));
|
||||
return isCode && !selectionStaysInsideElement(range, element);
|
||||
}
|
||||
|
||||
function selectionStaysInsideElement(range: Range, element: Element): boolean {
|
||||
return (
|
||||
range.startContainer !== element &&
|
||||
|
||||
@@ -63,10 +63,25 @@ const html = `<!doctype html>
|
||||
#diagram svg {
|
||||
display: block;
|
||||
}
|
||||
/* Keep Mermaid's temporary render container unscaled when the host mounts this frame
|
||||
at 0x0 or 240x240 before the diagram's own dimensions are known. */
|
||||
#staging-clip {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
#staging {
|
||||
width: 16384px;
|
||||
height: 16384px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="diagram"></div>
|
||||
<div id="staging-clip" aria-hidden="true"><div id="staging"></div></div>
|
||||
<script>${js}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -40,6 +40,9 @@ function initializeMermaid(colorScheme: "light" | "dark"): void {
|
||||
],
|
||||
flowchart: { htmlLabels: false },
|
||||
class: { htmlLabels: false },
|
||||
// A gantt chart takes the width of the element it renders in, which is the oversized staging
|
||||
// container. Keep it as wide as this frame, which on native is the message column.
|
||||
gantt: { useWidth: document.body.clientWidth },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,7 +100,15 @@ function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSi
|
||||
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
|
||||
try {
|
||||
initializeMermaid(message.colorScheme);
|
||||
const { svg } = await mermaid.render(`paseo-mermaid-${message.revision}`, message.source);
|
||||
const staging = document.getElementById("staging");
|
||||
if (!staging) {
|
||||
throw new Error("Missing Mermaid staging container");
|
||||
}
|
||||
const { svg } = await mermaid.render(
|
||||
`paseo-mermaid-${message.revision}`,
|
||||
message.source,
|
||||
staging,
|
||||
);
|
||||
if (message.revision !== latestRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+104
-1
@@ -29,9 +29,15 @@ function waitForRuntimeMessage(
|
||||
});
|
||||
}
|
||||
|
||||
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
|
||||
async function mountRuntime(
|
||||
size?: { width: number; height: number },
|
||||
options?: { allowSameOrigin?: boolean },
|
||||
): Promise<HTMLIFrameElement> {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.sandbox.add("allow-scripts");
|
||||
if (options?.allowSameOrigin) {
|
||||
frame.sandbox.add("allow-same-origin");
|
||||
}
|
||||
if (size) {
|
||||
frame.style.width = `${size.width}px`;
|
||||
frame.style.height = `${size.height}px`;
|
||||
@@ -112,6 +118,103 @@ describe("Mermaid sandbox runtime", () => {
|
||||
expect(renderedSize(narrow)).toEqual(renderedSize(wide));
|
||||
});
|
||||
|
||||
/**
|
||||
* MermaidFenceHost renders the first revision inside a 240x240 measuring frame (or 0x0 before
|
||||
* ResizeObserver settles), then resizes the frame to the reported diagram size without
|
||||
* re-rendering. Diagrams such as xychart-beta set `width="100%"` and `viewBox="0 0 700 500"`
|
||||
* before measuring axis text with `getBoundingClientRect()`, so rendering directly in a narrow
|
||||
* frame shrinks measured glyph boxes and leaves the Y-axis title colliding with tick labels and
|
||||
* X-axis labels clipped at the bottom edge.
|
||||
*/
|
||||
it("preserves unscaled SVG layout when rendered inside a narrow or zero-sized frame", async () => {
|
||||
const source = [
|
||||
"xychart-beta",
|
||||
' title "Throughput by Stage (req/s)"',
|
||||
' x-axis ["v1-base", "v2-parse", "v3-batch", "v4-cache", "v5-simd", "v6-final"]',
|
||||
' y-axis "Throughput (req/s)" 92.5 --> 93.6',
|
||||
" bar [92.7, 92.8, 93.1, 93.2, 93.3, 93.5]",
|
||||
].join("\n");
|
||||
const zeroFrame = await mountRuntime({ height: 0, width: 0 }, { allowSameOrigin: true });
|
||||
const measuringFrame = await mountRuntime(
|
||||
{ height: 240, width: 240 },
|
||||
{ allowSameOrigin: true },
|
||||
);
|
||||
const fullFrame = await mountRuntime({ height: 500, width: 700 }, { allowSameOrigin: true });
|
||||
|
||||
await render(zeroFrame, { revision: 1, source });
|
||||
await render(measuringFrame, { revision: 1, source });
|
||||
await render(fullFrame, { revision: 1, source });
|
||||
|
||||
for (const frame of [zeroFrame, measuringFrame]) {
|
||||
frame.style.width = "700px";
|
||||
frame.style.height = "500px";
|
||||
const doc = frame.contentDocument;
|
||||
if (!doc) {
|
||||
throw new Error("Expected inspectable runtime document");
|
||||
}
|
||||
const svgRect = doc.querySelector("#diagram svg")?.getBoundingClientRect();
|
||||
const yTitleRect = doc.querySelector(".left-axis .title text")?.getBoundingClientRect();
|
||||
const yLabels = Array.from(doc.querySelectorAll(".left-axis .label text"));
|
||||
const xLabels = Array.from(doc.querySelectorAll(".bottom-axis .label text"));
|
||||
expect(svgRect).not.toBeUndefined();
|
||||
expect(yTitleRect).not.toBeUndefined();
|
||||
expect(yLabels.length).toBeGreaterThan(0);
|
||||
expect(xLabels.length).toBeGreaterThan(0);
|
||||
|
||||
for (const label of yLabels) {
|
||||
expect(yTitleRect!.right).toBeLessThan(label.getBoundingClientRect().left);
|
||||
}
|
||||
for (const label of xLabels) {
|
||||
expect(label.getBoundingClientRect().bottom).toBeLessThanOrEqual(svgRect!.bottom);
|
||||
}
|
||||
}
|
||||
|
||||
function axisOrigin(frame: HTMLIFrameElement): { leftAxisX: number; bottomAxisY: number } {
|
||||
const doc = frame.contentDocument;
|
||||
const leftPath = doc?.querySelector(".left-axis .axisl-line path")?.getAttribute("d") ?? "";
|
||||
const bottomPath =
|
||||
doc?.querySelector(".bottom-axis .axis-line path")?.getAttribute("d") ?? "";
|
||||
const leftMatch = leftPath.match(/^M\s+([\d.]+),/);
|
||||
const bottomMatch = bottomPath.match(/^M\s+[\d.]+,([\d.]+)/);
|
||||
if (!leftMatch || !bottomMatch) {
|
||||
throw new Error(`Unexpected axis paths: ${leftPath} | ${bottomPath}`);
|
||||
}
|
||||
return {
|
||||
leftAxisX: Number.parseFloat(leftMatch[1]),
|
||||
bottomAxisY: Number.parseFloat(bottomMatch[1]),
|
||||
};
|
||||
}
|
||||
|
||||
const fullOrigin = axisOrigin(fullFrame);
|
||||
for (const candidate of [measuringFrame, zeroFrame]) {
|
||||
const origin = axisOrigin(candidate);
|
||||
expect(origin.leftAxisX).toBeCloseTo(fullOrigin.leftAxisX, 3);
|
||||
expect(origin.bottomAxisY).toBeCloseTo(fullOrigin.bottomAxisY, 3);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A gantt chart takes the width of the element Mermaid renders it in. Rendering inside the large
|
||||
* offscreen container must keep it as wide as the frame, which on native is the message column.
|
||||
*/
|
||||
it("renders a gantt chart as wide as the frame the host gives it", async () => {
|
||||
const source = [
|
||||
"gantt",
|
||||
" title Release plan",
|
||||
" dateFormat YYYY-MM-DD",
|
||||
" section Build",
|
||||
" Design :a1, 2024-01-01, 10d",
|
||||
" Implement :after a1, 20d",
|
||||
].join("\n");
|
||||
const widths = [];
|
||||
for (const width of [240, 350, 900]) {
|
||||
const frame = await mountRuntime({ height: 240, width });
|
||||
widths.push(renderedSize(await render(frame, { revision: 1, source })).width);
|
||||
}
|
||||
|
||||
expect(widths).toEqual([240, 350, 900]);
|
||||
});
|
||||
|
||||
it("coalesces queued input and never reports an obsolete result", async () => {
|
||||
const frame = await mountRuntime();
|
||||
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
|
||||
|
||||
@@ -106,29 +106,76 @@ function ProviderSettingsAction({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
/**
|
||||
* An icon button that explains itself in a tooltip on desktop hover. Tooltips
|
||||
* never open on mobile, so native renders the shared gesture-aware pressable
|
||||
* instead of a plain Pressable: inside the model list's independent scroll
|
||||
* viewport (Android, wide windows) the native scroll gesture cancels a plain
|
||||
* Pressable's touches and the button would never fire.
|
||||
*/
|
||||
function IconActionButton({
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
onPress,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
onPress: () => void;
|
||||
style?:
|
||||
| StyleProp<ViewStyle>
|
||||
| ((state: PressableStateCallbackType & { hovered?: boolean }) => StyleProp<ViewStyle>);
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (isNative) {
|
||||
return (
|
||||
<ModelBrowserPressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={style}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</ModelBrowserPressable>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
style={style}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("modelSelector.editProfilesLabel")}
|
||||
testID="model-profiles-edit"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
>
|
||||
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
|
||||
{children}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{t("modelSelector.editProfilesLabel")}</Text>
|
||||
<Text style={styles.tooltipText}>{accessibilityLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<IconActionButton
|
||||
onPress={onPress}
|
||||
style={iconButtonStyle}
|
||||
accessibilityLabel={t("modelSelector.editProfilesLabel")}
|
||||
testID="model-profiles-edit"
|
||||
>
|
||||
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
|
||||
</IconActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
const IndependentScrollGestureContext = createContext<ReturnType<typeof Gesture.Native> | null>(
|
||||
null,
|
||||
);
|
||||
@@ -637,24 +684,14 @@ function ModelRowProfileAction({
|
||||
[visible],
|
||||
);
|
||||
return (
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={pressableStyle}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<IconActionButton
|
||||
onPress={onPress}
|
||||
style={pressableStyle}
|
||||
accessibilityLabel={label}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</IconActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1690,8 +1727,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
rowIconButtonPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
// Hidden in style so every pressable branch that applies the style also stops taking taps.
|
||||
profileActionHidden: {
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
emptyState: {
|
||||
padding: theme.spacing[4],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Platform, ScrollView, Text, View } from "react-native";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ExternalLink, type ExternalLinkProps } from "@/components/ui/external-link";
|
||||
import { SettingsInfoTip } from "./settings-info-tip";
|
||||
|
||||
@@ -674,7 +674,11 @@ function WorkspaceRowRightGroup({
|
||||
{renderSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
|
||||
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
<SidebarWorkspaceTrailingContent
|
||||
workspace={workspace}
|
||||
trailing={trailing}
|
||||
backdrop={backdrop}
|
||||
/>
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay
|
||||
visible={kebab.showKebab}
|
||||
|
||||
@@ -58,6 +58,8 @@ import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
|
||||
import { useWorkspaceLabelProjection } from "@/workspace-labels";
|
||||
import { WorkspaceLabelDot } from "@/workspace-labels/swatch";
|
||||
import { WorkspaceLabelManagerModal } from "@/workspace-labels/manager-modal";
|
||||
import { PluginIconView } from "@/plugins/icons";
|
||||
import type { PluginWorkspaceRowBadgeItemGroup } from "@/plugins/workspace-row-badge-items";
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -65,6 +67,7 @@ const ThemedSettings2 = withUnistyles(Settings2);
|
||||
/** CI's mark: the subject of the checks row, and the shape the icon-only option leaves behind. */
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircle = withUnistyles(Circle);
|
||||
const ThemedPluginIconView = withUnistyles(PluginIconView);
|
||||
|
||||
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
|
||||
const OPTION_ICON_SIZE = 14;
|
||||
@@ -607,9 +610,9 @@ function OptionList<Value extends string>({
|
||||
}
|
||||
|
||||
/**
|
||||
* Two groups, split by the separator. Above it, what a row may say about a workspace — each one
|
||||
* independent. Below it, the one thing the slot to the right of the title holds, so picking the
|
||||
* one already showing empties the slot and gives the width back to the title.
|
||||
* Three groups: Paseo's own row items, plugin badge items, then the one thing the slot to the
|
||||
* right of the title holds. Picking the trailing choice already showing empties the slot and
|
||||
* gives the width back to the title.
|
||||
*
|
||||
* CI is the one item above the separator with three answers rather than two, so it opens a page
|
||||
* instead of ticking, and it goes last: a row that navigates does not belong in the middle of a
|
||||
@@ -632,6 +635,12 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
/>
|
||||
))}
|
||||
<ChecksSubTrigger />
|
||||
{preferences.pluginRowBadgeItems.length > 0 ? (
|
||||
<MenuSeparator testID="sidebar-plugin-row-badge-items-separator" />
|
||||
) : null}
|
||||
{preferences.pluginRowBadgeItems.map((item) => (
|
||||
<PluginRowBadgeItemOption key={item.key} item={item} preferences={preferences} />
|
||||
))}
|
||||
<MenuSeparator />
|
||||
{TRAILING_CHOICES.map((choice) => (
|
||||
<OptionItem
|
||||
@@ -649,6 +658,37 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** A plugin's workspace row badge item, toggled like the row's own. */
|
||||
function PluginRowBadgeItemOption({
|
||||
item,
|
||||
preferences,
|
||||
}: {
|
||||
item: PluginWorkspaceRowBadgeItemGroup;
|
||||
preferences: Preferences;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(
|
||||
() => preferences.togglePluginRowBadgeItem(item.key),
|
||||
[item.key, preferences],
|
||||
);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<ThemedPluginIconView icon={item.icon} size={OPTION_ICON_SIZE} uniProps={mutedIconMapping} />
|
||||
),
|
||||
[item.icon],
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
selected={preferences.isPluginRowBadgeItemVisible(item.key)}
|
||||
leading={leading}
|
||||
closeOnSelect={false}
|
||||
onSelect={handleSelect}
|
||||
testID={`sidebar-row-badge-item-plugin-${item.pluginId}-${item.itemId}`}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* No value on the row, only the chevron. The three answers are all long enough that the value
|
||||
* fills the row and stops reading as an answer sitting at the right edge — it turns into a second
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
type SidebarGroupMode,
|
||||
type SidebarLabelFilter,
|
||||
} from "@/stores/sidebar-view-store";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import {
|
||||
groupPluginWorkspaceRowBadgeItems,
|
||||
isWorkspaceRowBadgeItemVisible,
|
||||
type PluginWorkspaceRowBadgeItemGroup,
|
||||
} from "@/plugins/workspace-row-badge-items";
|
||||
import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay } from "./checks-display";
|
||||
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
|
||||
|
||||
@@ -27,6 +33,10 @@ export interface SidebarDisplayPreferences {
|
||||
setTitleSource: (source: WorkspaceTitleSource) => void;
|
||||
rowItems: SidebarRowItems;
|
||||
toggleRowItem: (item: SidebarRowItem) => void;
|
||||
/** Workspace row badge items contributed by installed plugins, one per plugin item across hosts. */
|
||||
pluginRowBadgeItems: readonly PluginWorkspaceRowBadgeItemGroup[];
|
||||
isPluginRowBadgeItemVisible: (key: string) => boolean;
|
||||
togglePluginRowBadgeItem: (key: string) => void;
|
||||
checksDisplay: SidebarChecksDisplay;
|
||||
setChecksDisplay: (display: SidebarChecksDisplay) => void;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
@@ -75,9 +85,30 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
sidebarWorkspaceTrailing,
|
||||
sidebarRowItems,
|
||||
sidebarChecksDisplay,
|
||||
sidebarPluginRowBadgeItems,
|
||||
},
|
||||
updateSettings,
|
||||
} = useAppSettings();
|
||||
const installedPlugins = useInstalledPlugins();
|
||||
const pluginRowBadgeItems = useMemo(
|
||||
() => groupPluginWorkspaceRowBadgeItems(installedPlugins),
|
||||
[installedPlugins],
|
||||
);
|
||||
const isPluginRowBadgeItemVisible = useCallback(
|
||||
(key: string) => isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
|
||||
[sidebarPluginRowBadgeItems],
|
||||
);
|
||||
const togglePluginRowBadgeItem = useCallback(
|
||||
(key: string) => {
|
||||
void updateSettings({
|
||||
sidebarPluginRowBadgeItems: {
|
||||
...sidebarPluginRowBadgeItems,
|
||||
[key]: !isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateSettings, sidebarPluginRowBadgeItems],
|
||||
);
|
||||
|
||||
const setTitleSource = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
@@ -123,6 +154,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
setTitleSource,
|
||||
rowItems: sidebarRowItems,
|
||||
toggleRowItem,
|
||||
pluginRowBadgeItems,
|
||||
isPluginRowBadgeItemVisible,
|
||||
togglePluginRowBadgeItem,
|
||||
checksDisplay: sidebarChecksDisplay,
|
||||
setChecksDisplay,
|
||||
trailing: sidebarWorkspaceTrailing,
|
||||
@@ -148,6 +182,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
setTitleSource,
|
||||
sidebarRowItems,
|
||||
toggleRowItem,
|
||||
pluginRowBadgeItems,
|
||||
isPluginRowBadgeItemVisible,
|
||||
togglePluginRowBadgeItem,
|
||||
sidebarChecksDisplay,
|
||||
setChecksDisplay,
|
||||
sidebarWorkspaceTrailing,
|
||||
|
||||
@@ -1029,7 +1029,11 @@ function StatusWorkspaceActionSlot({
|
||||
return (
|
||||
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
|
||||
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
<SidebarWorkspaceTrailingContent
|
||||
workspace={workspace}
|
||||
trailing={trailing}
|
||||
backdrop={backdrop}
|
||||
/>
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay
|
||||
visible={kebab.showKebab}
|
||||
|
||||
@@ -180,12 +180,14 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
||||
</View>
|
||||
<WorkspaceMetaRow
|
||||
backdrop={backdrop}
|
||||
currentBranch={workspace.currentBranch}
|
||||
projectName={leadingProjectName}
|
||||
hostBadge={hostBadge ?? null}
|
||||
prHint={workspace.prHint}
|
||||
serviceSummary={serviceSummary}
|
||||
labels={labels}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -15,7 +15,12 @@ import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
|
||||
import {
|
||||
PluginWorkspaceRowBadgeItems,
|
||||
usePluginWorkspaceRowBadgeItems,
|
||||
} from "@/plugins/workspace-row-badge-items";
|
||||
import { CheckIndicator } from "./check-indicator";
|
||||
import type { CheckSummary, CheckSummaryState } from "./check-summary";
|
||||
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
|
||||
@@ -59,6 +64,9 @@ const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
* state has its own colors — a label is a name a person chose, so it carries its own identity
|
||||
* color on a tint instead (see `WorkspaceLabelChip`). It sits last so the reported facts are
|
||||
* read first, and it stays the same height as the rest of the line.
|
||||
*
|
||||
* Plugin items follow everything the row knows itself. A plugin renders its slot as it likes:
|
||||
* the row adds no separator, since only the plugin knows whether it has anything to say.
|
||||
*/
|
||||
export function WorkspaceMetaRow({
|
||||
currentBranch,
|
||||
@@ -67,6 +75,8 @@ export function WorkspaceMetaRow({
|
||||
prHint,
|
||||
serviceSummary,
|
||||
labels = EMPTY_LABELS,
|
||||
workspace = null,
|
||||
backdrop,
|
||||
}: {
|
||||
currentBranch: string | null;
|
||||
projectName: string | null;
|
||||
@@ -74,6 +84,9 @@ export function WorkspaceMetaRow({
|
||||
prHint: PrHint | null;
|
||||
serviceSummary: WorkspaceServiceSummary | null;
|
||||
labels?: readonly WorkspaceLabelDefinition[];
|
||||
/** The real workspace behind the row; previews leave it out and get no plugin items. */
|
||||
workspace?: { serverId: string; workspaceId: string } | null;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
}) {
|
||||
const { rowItems, checksDisplay } = useSidebarMetaPreferences();
|
||||
const items = selectMetaRowItems({
|
||||
@@ -86,17 +99,31 @@ export function WorkspaceMetaRow({
|
||||
visible: rowItems,
|
||||
checksDisplay,
|
||||
});
|
||||
const pluginItems = usePluginWorkspaceRowBadgeItems(workspace?.serverId ?? null);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
if (items.length === 0 && (!workspace || pluginItems.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.kind}>
|
||||
{index > 0 ? <Text style={styles.separator}>·</Text> : null}
|
||||
{index > 0 ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.separator,
|
||||
backdrop === "surfaceSidebarHover" && styles.separatorHovered,
|
||||
backdrop === "surfaceSidebarSelected" && styles.separatorSelected,
|
||||
]}
|
||||
>
|
||||
·
|
||||
</Text>
|
||||
) : null}
|
||||
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
|
||||
</Fragment>
|
||||
))}
|
||||
{workspace ? (
|
||||
<PluginWorkspaceRowBadgeItems targets={pluginItems} workspaceId={workspace.workspaceId} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -348,6 +375,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
lineHeight: 16,
|
||||
flexShrink: 0,
|
||||
},
|
||||
separatorHovered: { color: theme.colors.foregroundSidebarHover },
|
||||
separatorSelected: { color: theme.colors.foregroundSidebarSelected },
|
||||
// Tighter than the line's own gap so a run of chips reads as one item — see `LabelsItem`.
|
||||
// Shrinks at the same weight as the service name: both are arbitrary text somebody chose, so
|
||||
// a crowded line takes from the two of them in proportion rather than emptying one of them.
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
|
||||
export type { SidebarWorkspaceTrailing };
|
||||
|
||||
@@ -39,9 +40,11 @@ export function hasSidebarWorkspaceTrailing({
|
||||
export function SidebarWorkspaceTrailingContent({
|
||||
workspace,
|
||||
trailing,
|
||||
backdrop,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
}) {
|
||||
if (trailing === "diff" && workspace.diffStat) {
|
||||
return (
|
||||
@@ -49,7 +52,7 @@ export function SidebarWorkspaceTrailingContent({
|
||||
);
|
||||
}
|
||||
if (trailing === "timestamp" && workspace.statusEnteredAt) {
|
||||
return <WorkspaceTimestamp enteredAt={workspace.statusEnteredAt} />;
|
||||
return <WorkspaceTimestamp enteredAt={workspace.statusEnteredAt} backdrop={backdrop} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -59,10 +62,24 @@ export function SidebarWorkspaceTrailingContent({
|
||||
* re-renders the component that owns it — keeping that component down to a single `<Text>` is
|
||||
* what stops a minute tick from reaching the row, the list, or the diff stat next door.
|
||||
*/
|
||||
function WorkspaceTimestamp({ enteredAt }: { enteredAt: Date }) {
|
||||
function WorkspaceTimestamp({
|
||||
enteredAt,
|
||||
backdrop,
|
||||
}: {
|
||||
enteredAt: Date;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
}) {
|
||||
const label = useCompactTimeAgo(enteredAt);
|
||||
return (
|
||||
<Text style={styles.timestamp} numberOfLines={1} testID="sidebar-workspace-timestamp">
|
||||
<Text
|
||||
style={[
|
||||
styles.timestamp,
|
||||
backdrop === "surfaceSidebarHover" && styles.hovered,
|
||||
backdrop === "surfaceSidebarSelected" && styles.selected,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
testID="sidebar-workspace-timestamp"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
@@ -80,4 +97,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
flexShrink: 0,
|
||||
},
|
||||
hovered: { color: theme.colors.foregroundSidebarHover },
|
||||
selected: { color: theme.colors.foregroundSidebarSelected },
|
||||
}));
|
||||
|
||||
@@ -51,10 +51,7 @@ import {
|
||||
TERMINAL_SELECTION_GRIP_SIZE,
|
||||
} from "../terminal/native-renderer/terminal-selection-controller";
|
||||
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
|
||||
import {
|
||||
forwardNativeTerminalKey,
|
||||
type NativeTerminalKey,
|
||||
} from "../terminal/native-renderer/terminal-key-events";
|
||||
import { type NativeTerminalKeyEvent } from "../terminal/native-renderer/terminal-key-events";
|
||||
import {
|
||||
TerminalInput,
|
||||
type TerminalInputHandle,
|
||||
@@ -511,9 +508,9 @@ function NativeTerminalEmulator({
|
||||
);
|
||||
|
||||
const handleNativeTerminalKey = useCallback(
|
||||
(key: NativeTerminalKey) => {
|
||||
(event: NativeTerminalKeyEvent) => {
|
||||
claimActiveTerminalSizeForAction("key");
|
||||
forwardNativeTerminalKey({ key, onTerminalKey: callbacksRef.current.onTerminalKey });
|
||||
callbacksRef.current.onTerminalKey?.(event);
|
||||
},
|
||||
[claimActiveTerminalSizeForAction],
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useContainedControlSize } from "@/components/ui/control-size";
|
||||
import {
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import type {
|
||||
PressableProps,
|
||||
PressableStateCallbackType,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import {
|
||||
|
||||
@@ -88,6 +88,18 @@ describe("bottom sheet visibility tracker", () => {
|
||||
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("dismisses again when a sheet hidden while opening finishes opening anyway", () => {
|
||||
const { sheet, tracker } = setup();
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
// The owner hides the sheet before its open animation reports in; the sheet still lands open.
|
||||
tracker.syncDesired({ visible: false });
|
||||
tracker.handleSheetIndexChange(0);
|
||||
|
||||
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("waits to present until the sheet controller becomes available", () => {
|
||||
const { sheet, tracker } = setup();
|
||||
tracker.syncDesired({ visible: true });
|
||||
|
||||
@@ -120,6 +120,11 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
if (phase === "presenting" || phase === "dismissing") {
|
||||
setPhase("presented");
|
||||
}
|
||||
// A dismiss sent before the sheet measured its layout is dropped, and the sheet still
|
||||
// finishes opening. The owner wants it hidden, so it goes again.
|
||||
if (!visible && isEnabled !== false) {
|
||||
dismiss();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (phase === "presenting" || phase === "presented") {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { draftCreationKey } from "./creation-key";
|
||||
|
||||
const request = {
|
||||
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
|
||||
workspaceId: "workspace-1",
|
||||
initialPrompt: "Start the original task.",
|
||||
clientMessageId: "draft-1:initial-message",
|
||||
};
|
||||
|
||||
describe("draftCreationKey", () => {
|
||||
it("keeps the key for a retry of the same request, whatever its key order", () => {
|
||||
const reordered = {
|
||||
clientMessageId: request.clientMessageId,
|
||||
initialPrompt: request.initialPrompt,
|
||||
workspaceId: request.workspaceId,
|
||||
config: { model: "gpt-6", cwd: "/repo", provider: "codex" },
|
||||
};
|
||||
expect(draftCreationKey("draft-1", reordered)).toBe(draftCreationKey("draft-1", request));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "prompt", edited: { ...request, initialPrompt: "Start the edited task." } },
|
||||
{ name: "model", edited: { ...request, config: { ...request.config, model: "gpt-6-mini" } } },
|
||||
])("gives an edited $name a new key on the same draft", ({ edited }) => {
|
||||
const key = draftCreationKey("draft-1", edited);
|
||||
expect(key).not.toBe(draftCreationKey("draft-1", request));
|
||||
expect(key.startsWith("draft-1:")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The creation key for a draft's request. The daemon keeps one request per key, so retrying the
|
||||
* same draft resumes its creation, while an edited draft is a new request with its own key.
|
||||
*/
|
||||
export function draftCreationKey(draftId: string, request: object): string {
|
||||
return `${draftId}:${hash(canonicalJson(request))}`;
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, entry: unknown) =>
|
||||
entry && typeof entry === "object" && !Array.isArray(entry)
|
||||
? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)))
|
||||
: entry,
|
||||
);
|
||||
}
|
||||
|
||||
// 53-bit cyrb53: distinguishes the edits of one draft without WebCrypto, which needs HTTPS.
|
||||
function hash(text: string): string {
|
||||
let h1 = 0xdeadbeef;
|
||||
let h2 = 0x41c6ce57;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const code = text.charCodeAt(index);
|
||||
h1 = Math.imul(h1 ^ code, 2654435761);
|
||||
h2 = Math.imul(h2 ^ code, 1597334677);
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "@/workspace-tabs/model";
|
||||
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { draftCreationKey } from "@/composer/draft/creation-key";
|
||||
|
||||
interface AutoSubmitConfig {
|
||||
provider: string;
|
||||
@@ -164,8 +165,7 @@ async function submitDraftCreateRequest(input: {
|
||||
|
||||
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
||||
const imagesData = await encodeImages(images);
|
||||
const options = {
|
||||
idempotencyKey: input.draftId,
|
||||
const request = {
|
||||
config,
|
||||
workspaceId,
|
||||
initialPrompt: text,
|
||||
@@ -174,8 +174,10 @@ async function submitDraftCreateRequest(input: {
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
||||
};
|
||||
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId];
|
||||
const result = creation ? await creation.retry(options) : await client.createAgent(options);
|
||||
const result = await client.createAgent({
|
||||
...request,
|
||||
idempotencyKey: draftCreationKey(input.draftId, request),
|
||||
});
|
||||
|
||||
return {
|
||||
agentId: result.id,
|
||||
|
||||
@@ -97,7 +97,7 @@ import {
|
||||
} from "@/components/ui/text-input";
|
||||
|
||||
const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.surface4,
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
import {
|
||||
resolveSendTooltipLabel,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
import { createForgeShorthand, type ForgeShorthand } from "./shorthand";
|
||||
|
||||
@@ -9,13 +10,27 @@ const ForgeShorthandContext = createContext<ForgeShorthand | null>(null);
|
||||
export function ForgeShorthandProvider({
|
||||
forge,
|
||||
remoteUrl,
|
||||
forgeWeb,
|
||||
children,
|
||||
}: {
|
||||
forge: string | null | undefined;
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb: ForgeWeb | null | undefined;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = useMemo(() => createForgeShorthand({ forge, remoteUrl }), [forge, remoteUrl]);
|
||||
// Keyed on the strings: the descriptor hands over a new `forgeWeb` object whenever any
|
||||
// other workspace field changes, and a new context value re-renders every Markdown message.
|
||||
const webHost = forgeWeb?.host;
|
||||
const referenceRepository = forgeWeb?.referenceRepository;
|
||||
const value = useMemo(
|
||||
() =>
|
||||
createForgeShorthand({
|
||||
forge,
|
||||
remoteUrl,
|
||||
forgeWeb: webHost ? { host: webHost, referenceRepository } : null,
|
||||
}),
|
||||
[forge, remoteUrl, webHost, referenceRepository],
|
||||
);
|
||||
return <ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,17 @@ describe("createForgeShorthand", () => {
|
||||
expect(gitlab.resolve("g/sub/q#3")).toBe("https://gitlab.com/g/sub/q/-/issues/3");
|
||||
});
|
||||
|
||||
it("links numbers to the repository the daemon says owns them, but keeps explicit references", () => {
|
||||
const fork = createForgeShorthand({
|
||||
forge: "github",
|
||||
remoteUrl: "git@github.com-work:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
})!;
|
||||
|
||||
expect(fork.resolve("#123")).toBe("https://github.com/main-owner/repo/issues/123");
|
||||
expect(fork.resolve("acme/tools#4")).toBe("https://github.com/acme/tools/issues/4");
|
||||
});
|
||||
|
||||
it("only recognizes the sigils the forge declares", () => {
|
||||
expect(matches(github, "see #1 and !2")).toEqual(["#1"]);
|
||||
expect(github.resolve("!2")).toBeNull();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
|
||||
import { forgeFromRemoteUrl } from "@/git/forge";
|
||||
import { getClientForgeLogicModule } from "@/git/forges";
|
||||
@@ -26,12 +27,15 @@ const REFERENCE = new RegExp(String.raw`^(?:(${REPO}))?([#!])(${NUMBER})$`);
|
||||
export function createForgeShorthand(input: {
|
||||
forge: string | null | undefined;
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
}): ForgeShorthand | null {
|
||||
const remoteUrl = input.remoteUrl;
|
||||
const forge = input.forge || forgeFromRemoteUrl(remoteUrl);
|
||||
if (!forge || !remoteUrl) return null;
|
||||
const sigils = forgeSigils(forge).filter(
|
||||
(sigil) => buildForgeReferenceUrl(forge, { remoteUrl, sigil, number: 1 }) !== null,
|
||||
(sigil) =>
|
||||
buildForgeReferenceUrl(forge, { remoteUrl, forgeWeb: input.forgeWeb, sigil, number: 1 }) !==
|
||||
null,
|
||||
);
|
||||
if (sigils.length === 0) return null;
|
||||
return {
|
||||
@@ -41,6 +45,7 @@ export function createForgeShorthand(input: {
|
||||
if (!match) return null;
|
||||
return buildForgeReferenceUrl(forge, {
|
||||
remoteUrl,
|
||||
forgeWeb: input.forgeWeb,
|
||||
repo: match[1] ?? null,
|
||||
sigil: match[2] as ForgeReferenceSigil,
|
||||
number: Number(match[3]),
|
||||
|
||||
@@ -326,3 +326,81 @@ describe("hasForgeWebUrls", () => {
|
||||
expect(hasForgeWebUrls("bitbucket")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("links resolved by the daemon (forgeWeb)", () => {
|
||||
const sshAliasRemote = "git@github.com-work:acme/repo.git";
|
||||
|
||||
it("links #N on the web host the daemon resolved for an SSH config alias", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("github", {
|
||||
remoteUrl: sshAliasRemote,
|
||||
forgeWeb: { host: "github.com" },
|
||||
sigil: "#",
|
||||
number: 1,
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/issues/1");
|
||||
});
|
||||
|
||||
it("opens the branch and a file on the resolved web host for an SSH config alias", () => {
|
||||
const forgeWeb = { host: "github.com" };
|
||||
expect(
|
||||
buildForgeBranchTreeUrl("github", { remoteUrl: sshAliasRemote, forgeWeb, branch: "main" }),
|
||||
).toBe("https://github.com/acme/repo/tree/main");
|
||||
expect(
|
||||
buildForgeBlobUrl("github", {
|
||||
remoteUrl: sshAliasRemote,
|
||||
forgeWeb,
|
||||
branch: "main",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
|
||||
});
|
||||
|
||||
it("links #N to the repository that owns the numbers, such as a fork's parent", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("github", {
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
sigil: "#",
|
||||
number: 123,
|
||||
}),
|
||||
).toBe("https://github.com/main-owner/repo/issues/123");
|
||||
});
|
||||
|
||||
it("keeps an explicit owner/repo#N reference on the named repository", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("github", {
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
repo: "other/tools",
|
||||
sigil: "#",
|
||||
number: 7,
|
||||
}),
|
||||
).toBe("https://github.com/other/tools/issues/7");
|
||||
});
|
||||
|
||||
it("keeps the branch and file links on the origin repository for a fork", () => {
|
||||
const input = {
|
||||
remoteUrl: "git@github.com:fork-owner/repo.git",
|
||||
forgeWeb: { host: "github.com", referenceRepository: "main-owner/repo" },
|
||||
branch: "feature/only-on-fork",
|
||||
};
|
||||
expect(buildForgeBranchTreeUrl("github", input)).toBe(
|
||||
"https://github.com/fork-owner/repo/tree/feature/only-on-fork",
|
||||
);
|
||||
expect(buildForgeBlobUrl("github", { ...input, path: "a.ts" })).toBe(
|
||||
"https://github.com/fork-owner/repo/blob/feature/only-on-fork/a.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the scheme and port of a self-hosted http remote", () => {
|
||||
expect(
|
||||
buildForgeReferenceUrl("gitea", {
|
||||
remoteUrl: "http://forge.test:3000/team/repo.git",
|
||||
forgeWeb: { host: "forge.test", referenceRepository: "upstream/repo" },
|
||||
sigil: "#",
|
||||
number: 12,
|
||||
}),
|
||||
).toBe("http://forge.test:3000/upstream/repo/issues/12");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,17 +6,23 @@
|
||||
* format); an unknown forge has no grammar and yields null, so the action is
|
||||
* simply absent rather than wrong.
|
||||
*
|
||||
* URL grammar lives on each client forge module. The repo identity and host
|
||||
* both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias
|
||||
* (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is.
|
||||
* URL grammar lives on each client forge module. The daemon resolves the web
|
||||
* host (SSH config aliases, cloud SSH hosts) and the repository that owns
|
||||
* issue and change-request numbers (a fork's parent) and publishes them as
|
||||
* `forgeWeb`; branch and file links stay on the origin repository.
|
||||
*/
|
||||
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
|
||||
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import {
|
||||
forgeWebHost,
|
||||
isForgeCloudHost,
|
||||
parseGitRemoteLocation,
|
||||
} from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeWeb } from "@getpaseo/protocol/messages";
|
||||
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
|
||||
import { getClientForgeLogicModule } from "@/git/forges";
|
||||
|
||||
export interface ForgeBlobUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
branch: string | null | undefined;
|
||||
path: string | null | undefined;
|
||||
lineStart?: number;
|
||||
@@ -25,12 +31,17 @@ export interface ForgeBlobUrlInput {
|
||||
|
||||
export interface ForgeBranchTreeUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
branch: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface ForgeReferenceUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
/** Repository path of a cross-repository reference; the remote's repository when absent. */
|
||||
forgeWeb?: ForgeWeb | null;
|
||||
/**
|
||||
* Repository path of a cross-repository reference; the repository that owns
|
||||
* issue numbers (`forgeWeb.referenceRepository`, else the remote's) when absent.
|
||||
*/
|
||||
repo?: string | null;
|
||||
sigil: ForgeReferenceSigil;
|
||||
number: number;
|
||||
@@ -62,13 +73,6 @@ interface ForgeWebLocation {
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web host + repo path from a remote. The host is the remote's host, except a
|
||||
* cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g.
|
||||
* ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`).
|
||||
* Self-hosted hosts are returned untouched. The repo path supports nested
|
||||
* groups (e.g. GitLab subgroups) since it is the full remote path.
|
||||
*/
|
||||
function isValidRepoPath(path: string): boolean {
|
||||
const segments = path.split("/").filter((segment) => segment.length > 0);
|
||||
if (segments.length === 0) {
|
||||
@@ -77,9 +81,17 @@ function isValidRepoPath(path: string): boolean {
|
||||
return !segments.includes("..");
|
||||
}
|
||||
|
||||
/**
|
||||
* Web location of the origin repository. The host is the daemon's
|
||||
* `forgeWeb.host`. A daemon without `forgeWeb` publishes none, so the remote's
|
||||
* own host is used, with a cloud SSH alias mapped to the forge's web host.
|
||||
* The repo path supports nested groups (e.g. GitLab subgroups) since it is
|
||||
* the full remote path.
|
||||
*/
|
||||
function resolveForgeWebLocation(
|
||||
forge: string,
|
||||
remoteUrl: string | null | undefined,
|
||||
forgeWeb: ForgeWeb | null | undefined,
|
||||
): ForgeWebLocation | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
@@ -88,9 +100,9 @@ function resolveForgeWebLocation(
|
||||
if (!location || !isValidRepoPath(location.path)) {
|
||||
return null;
|
||||
}
|
||||
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
|
||||
const isCloudHost = cloudHosts.includes(location.host);
|
||||
const webHost = isCloudHost ? cloudHosts[0] : location.host;
|
||||
// COMPAT(forgeWeb): added in v0.16.0, remove the remote-derived host after 2027-06-01 once daemon floor >= v0.16.0.
|
||||
const webHost = forgeWeb?.host ?? forgeWebHost(forge, location.host);
|
||||
const isCloudHost = isForgeCloudHost(forge, location.host);
|
||||
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
|
||||
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
|
||||
// web port, and a canonicalized cloud host always serves on the default port.
|
||||
@@ -139,7 +151,7 @@ export function buildForgeBranchTreeUrl(
|
||||
input: ForgeBranchTreeUrlInput,
|
||||
): string | null {
|
||||
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
|
||||
const branch = input.branch?.trim();
|
||||
if (!grammar || !location || !branch || branch === "HEAD") {
|
||||
return null;
|
||||
@@ -149,7 +161,7 @@ export function buildForgeBranchTreeUrl(
|
||||
|
||||
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
|
||||
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
|
||||
const branch = input.branch?.trim();
|
||||
const filePath = normalizeBlobPath(input.path);
|
||||
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
|
||||
@@ -175,8 +187,8 @@ export function buildForgeReferenceUrl(
|
||||
const referencePath = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths?.find(
|
||||
(path) => path.sigil === input.sigil,
|
||||
);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const repo = input.repo ?? location?.repo;
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl, input.forgeWeb);
|
||||
const repo = input.repo ?? input.forgeWeb?.referenceRepository ?? location?.repo;
|
||||
if (
|
||||
!referencePath ||
|
||||
!location ||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { useMemo, useCallback, useRef, useSyncExternalStore } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { useAllDirectoryDemand } from "./use-directory-demand";
|
||||
|
||||
export interface AggregatedAgent extends AgentDirectoryEntry {
|
||||
serverId: string;
|
||||
@@ -19,6 +20,9 @@ export interface AggregatedAgentsResult {
|
||||
refreshAll: () => void;
|
||||
}
|
||||
|
||||
const NO_AGENT_MAPS: Record<string, Map<string, Agent> | undefined> = Object.freeze({});
|
||||
const NO_AGENTS: AggregatedAgent[] = [];
|
||||
|
||||
export function useAggregatedAgents(options?: {
|
||||
includeArchived?: boolean;
|
||||
demand?: boolean;
|
||||
@@ -28,19 +32,21 @@ export function useAggregatedAgents(options?: {
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const demand = options?.demand ?? true;
|
||||
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
|
||||
useEffect(() => {
|
||||
if (!demand) return;
|
||||
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
|
||||
return () => releases.forEach((release) => release());
|
||||
}, [demand, runtime, serverIds]);
|
||||
useAllDirectoryDemand({ serverIds, enabled: demand });
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
|
||||
// The caller has said it does not need the directory, so stay off the agent maps entirely:
|
||||
// every map change otherwise re-renders this hook and rebuilds the whole list for a surface
|
||||
// that is not on screen (the command center while it is closed).
|
||||
const sessionAgents = useSessionStore(
|
||||
useShallow((state) => {
|
||||
if (!demand) {
|
||||
return NO_AGENT_MAPS;
|
||||
}
|
||||
const result: Record<string, Map<string, Agent> | undefined> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.agents;
|
||||
@@ -64,6 +70,16 @@ export function useAggregatedAgents(options?: {
|
||||
const result = useMemo(() => {
|
||||
// runtimeVersion is referenced so the memo recomputes when runtime state changes.
|
||||
void runtimeVersion;
|
||||
if (!demand) {
|
||||
prevAgentsRef.current = new Map();
|
||||
prevSortedRef.current = NO_AGENTS;
|
||||
return {
|
||||
agents: NO_AGENTS,
|
||||
isLoading: false,
|
||||
isInitialLoad: false,
|
||||
isRevalidating: false,
|
||||
};
|
||||
}
|
||||
const allAgents: AggregatedAgent[] = [];
|
||||
const serverLabelById = new Map(
|
||||
daemons.map((daemon) => [daemon.serverId, daemon.label] as const),
|
||||
@@ -158,7 +174,7 @@ export function useAggregatedAgents(options?: {
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
};
|
||||
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
|
||||
}, [daemons, demand, includeArchived, runtime, runtimeVersion, sessionAgents]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -14,3 +14,21 @@ export function useDirectoryDemand(serverId: string | null | undefined): void {
|
||||
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
|
||||
}, [serverId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds directory demand for several hosts at once, so a caller that needs the whole fleet
|
||||
* (the favicon, the aggregated agent list) does not have to subscribe to every agent map to
|
||||
* keep the directories loading.
|
||||
*/
|
||||
export function useAllDirectoryDemand(input: {
|
||||
serverIds: readonly string[];
|
||||
enabled: boolean;
|
||||
}): void {
|
||||
const { serverIds, enabled } = input;
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const runtime = getHostRuntimeStore();
|
||||
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
|
||||
return () => releases.forEach((release) => release());
|
||||
}, [enabled, serverIds]);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||
import { useAllDirectoryDemand } from "./use-directory-demand";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { useWorkspaceStatusesForBadges } from "@/stores/session-store-hooks";
|
||||
import { deriveMacDockBadgeCountFromWorkspaceStatuses } from "@/utils/desktop-badge-state";
|
||||
import { createMacDockBadgeCountSelector } from "@/utils/desktop-badge-state";
|
||||
import { createFaviconStatusSelector, type FaviconStatus } from "@/utils/favicon-status";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
type FaviconStatus = "none" | "running" | "attention";
|
||||
type ColorScheme = "dark" | "light";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
@@ -24,21 +25,6 @@ const FAVICON_IMAGES: Record<ColorScheme, Record<FaviconStatus, { uri: string }
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
function deriveFaviconStatus(
|
||||
agents: ReturnType<typeof useAggregatedAgents>["agents"],
|
||||
): FaviconStatus {
|
||||
const hasRunning = agents.some((agent) => agent.status === "running");
|
||||
if (hasRunning) {
|
||||
return "running";
|
||||
}
|
||||
const hasAttention = agents.some((agent) => agent.requiresAttention);
|
||||
const hasNeedsInput = agents.some((agent) => (agent.pendingPermissionCount ?? 0) > 0);
|
||||
if (hasAttention || hasNeedsInput) {
|
||||
return "attention";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
|
||||
const image = FAVICON_IMAGES[colorScheme][status];
|
||||
if (typeof image === "object" && "uri" in image) {
|
||||
@@ -94,8 +80,15 @@ async function updateMacDockBadge(count?: number) {
|
||||
}
|
||||
|
||||
export function useFaviconStatus() {
|
||||
const { agents } = useAggregatedAgents({ demand: !isNative });
|
||||
const workspaceStatuses = useWorkspaceStatusesForBadges();
|
||||
const daemons = useHosts();
|
||||
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
|
||||
// The favicon reads the whole fleet, so it has to keep every host's directory loading even
|
||||
// though it no longer subscribes to the agent maps.
|
||||
useAllDirectoryDemand({ serverIds, enabled: !isNative });
|
||||
const selectFaviconStatus = useMemo(createFaviconStatusSelector, []);
|
||||
const selectMacDockBadgeCount = useMemo(createMacDockBadgeCountSelector, []);
|
||||
const status = useSessionStore(selectFaviconStatus);
|
||||
const dockBadgeCount = useSessionStore(selectMacDockBadgeCount);
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
||||
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
|
||||
|
||||
@@ -112,17 +105,15 @@ export function useFaviconStatus() {
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
// Update favicon when agents or color scheme changes
|
||||
useEffect(() => {
|
||||
if (isNative) return;
|
||||
|
||||
const status = deriveFaviconStatus(agents);
|
||||
updateFavicon(status, colorScheme);
|
||||
}, [status, colorScheme]);
|
||||
|
||||
const dockBadgeCount = deriveMacDockBadgeCountFromWorkspaceStatuses(workspaceStatuses);
|
||||
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
|
||||
lastDockBadgeCountRef.current = dockBadgeCount;
|
||||
void updateMacDockBadge(dockBadgeCount);
|
||||
}
|
||||
}, [agents, colorScheme, workspaceStatuses]);
|
||||
useEffect(() => {
|
||||
if (isNative) return;
|
||||
if (dockBadgeCount === lastDockBadgeCountRef.current) return;
|
||||
lastDockBadgeCountRef.current = dockBadgeCount;
|
||||
void updateMacDockBadge(dockBadgeCount);
|
||||
}, [dockBadgeCount]);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ export interface AppSettings {
|
||||
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
|
||||
sidebarRowItems: SidebarRowItems;
|
||||
sidebarChecksDisplay: SidebarChecksDisplay;
|
||||
/** Plugin workspace row badge items by `<pluginId>/<itemId>`; a missing key shows. */
|
||||
sidebarPluginRowBadgeItems: Record<string, boolean>;
|
||||
/** Top-level sidebar rows in display order; empty means the default order, all visible. */
|
||||
sidebarNavItems: SidebarNavPreference[];
|
||||
/** Sidebar footer items in display order; empty means the default order, all visible. */
|
||||
@@ -156,6 +158,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sidebarWorkspaceTrailing: "timestamp",
|
||||
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||||
sidebarPluginRowBadgeItems: {},
|
||||
sidebarNavItems: [],
|
||||
sidebarFooterItems: [],
|
||||
usage: DEFAULT_USAGE_PREFERENCES,
|
||||
@@ -253,6 +256,7 @@ const StoredAppSettingsSchema = z
|
||||
.enum(["iconAndText", "icon", "none"])
|
||||
.optional()
|
||||
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
|
||||
sidebarPluginRowBadgeItems: z.record(z.string(), z.boolean()).catch({}),
|
||||
sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
usage: UsagePreferencesSchema,
|
||||
|
||||
@@ -8,6 +8,8 @@ export {
|
||||
subscribeOverlays,
|
||||
} from "./internal/overlay-dispatch";
|
||||
export { useKeyboardFocusSurface } from "./internal/use-focus-surface";
|
||||
export { subscribeHardwareKeyboard } from "./internal/sources/hardware";
|
||||
export type { HardwareKeyboardEvent } from "./internal/sources/hardware";
|
||||
export { usesDesktopBindings, supportsConfigurableComposerSendKeys } from "./internal/conventions";
|
||||
export type { BrowserKeyboardPolicy } from "./internal/sources/electron-browser";
|
||||
export { useHardwareKeyboardPresent } from "./availability";
|
||||
|
||||
@@ -4,20 +4,32 @@ export type KeyboardFocusSurface = "message-input" | "terminal";
|
||||
|
||||
export function createKeyboardFocusScope(hasEditableFocus: () => boolean = () => false) {
|
||||
const surfaces = new Map<symbol, KeyboardFocusSurface>();
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
return {
|
||||
declare(surface: KeyboardFocusSurface) {
|
||||
const token = Symbol(surface);
|
||||
surfaces.set(token, surface);
|
||||
notify();
|
||||
return () => {
|
||||
surfaces.delete(token);
|
||||
if (surfaces.delete(token)) notify();
|
||||
};
|
||||
},
|
||||
resolve(commandCenterOpen: boolean): KeyboardFocusScope {
|
||||
/** Pass `editableFocused` to resolve for a text control that is not a declared surface. */
|
||||
resolve(commandCenterOpen: boolean, editableFocused = hasEditableFocus()): KeyboardFocusScope {
|
||||
if (commandCenterOpen) return "command-center";
|
||||
let focused: KeyboardFocusScope = hasEditableFocus() ? "editable" : "other";
|
||||
let focused: KeyboardFocusScope = editableFocused ? "editable" : "other";
|
||||
for (const surface of surfaces.values()) focused = surface;
|
||||
return focused;
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
export const focusSurfaces = createKeyboardFocusScope(hasFocusedTextInput);
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface KeyboardSourceHandlers {
|
||||
keyUp(event: KeyboardInputEvent): void;
|
||||
blur(): void;
|
||||
commandCenterOpen(): boolean;
|
||||
subscribeCommandCenter(listener: () => void): () => void;
|
||||
}
|
||||
export function nativeKeyboardInput(
|
||||
event: KeyboardInputEvent,
|
||||
@@ -23,5 +24,6 @@ export interface KeyboardPolicyInput {
|
||||
bindings: readonly ParsedShortcutBinding[];
|
||||
chordState?: ChordState;
|
||||
isMac: boolean;
|
||||
usesDesktopBindings: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -366,7 +366,13 @@ describe("Electron guest source through shared resolution", () => {
|
||||
},
|
||||
captureFocusRestore: (id) => restored.push(id),
|
||||
},
|
||||
{ keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false },
|
||||
{
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
},
|
||||
);
|
||||
receive({
|
||||
browserId: "tab-1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeyboardModule {
|
||||
getHardwareKeyboardPresent(): boolean;
|
||||
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
|
||||
@@ -19,6 +19,9 @@ export function subscribeHardwareKeyboard(
|
||||
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
|
||||
module.setOverlayKeys(keys);
|
||||
}
|
||||
// A focused Android EditText types nothing for Tab, Shift+Tab or Alt+Shift+[,
|
||||
// so there is no character to take from it.
|
||||
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return module.getHardwareKeyboardPresent();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeyboardModule {
|
||||
getHardwareKeyboardPresent(): boolean;
|
||||
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
|
||||
setOverlayKeys(keys: readonly string[]): void;
|
||||
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
|
||||
addListener(
|
||||
event: "onHardwareKeyboardShortcut",
|
||||
handler: (event: HardwareKeyboardEvent) => void,
|
||||
@@ -19,6 +20,9 @@ export function subscribeHardwareKeyboard(
|
||||
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
|
||||
module.setOverlayKeys(keys);
|
||||
}
|
||||
export function setHardwareKeyboardClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void {
|
||||
module.setClaimedShortcuts(shortcuts);
|
||||
}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return module.getHardwareKeyboardPresent();
|
||||
|
||||
@@ -3,12 +3,19 @@ export interface HardwareKeyboardEvent extends KeyboardShortcutInput {
|
||||
isComposing: boolean;
|
||||
phase: "down" | "up";
|
||||
}
|
||||
/** A shortcut the native window takes from the focused text control, by DOM `code`. */
|
||||
export interface ClaimedShortcut {
|
||||
code: string;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
}
|
||||
export function subscribeHardwareKeyboard(
|
||||
_handler: (event: HardwareKeyboardEvent) => void,
|
||||
): () => void {
|
||||
return () => {};
|
||||
}
|
||||
export function setHardwareKeyboardOverlayKeys(_keys: readonly string[]): void {}
|
||||
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return false;
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { KeyboardSourceHandlers } from "./contract";
|
||||
import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware";
|
||||
import { attachHardwareKeyboardSource } from "./native";
|
||||
import type { KeyboardPolicyInput, KeyboardSourceHandlers } from "./contract";
|
||||
import {
|
||||
setHardwareKeyboardClaimedShortcuts,
|
||||
setHardwareKeyboardOverlayKeys,
|
||||
subscribeHardwareKeyboard,
|
||||
} from "./hardware";
|
||||
import { createHardwareKeyboardSource } from "./native";
|
||||
const hardwareKeyboard = createHardwareKeyboardSource({
|
||||
subscribe: subscribeHardwareKeyboard,
|
||||
setOverlayKeys: setHardwareKeyboardOverlayKeys,
|
||||
setClaimedShortcuts: setHardwareKeyboardClaimedShortcuts,
|
||||
});
|
||||
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
|
||||
return attachHardwareKeyboardSource(
|
||||
{ subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys },
|
||||
handlers,
|
||||
);
|
||||
return hardwareKeyboard.attach(handlers);
|
||||
}
|
||||
|
||||
export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {}
|
||||
export function publishKeyboardPolicy(input: KeyboardPolicyInput): void {
|
||||
hardwareKeyboard.publishPolicy(input);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { usesDesktopBindings } from "../conventions";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
@@ -26,7 +25,7 @@ export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => v
|
||||
}
|
||||
export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void {
|
||||
const policy = input.enabled
|
||||
? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings })
|
||||
? buildBrowserKeyboardPolicy(input)
|
||||
: { menuPrefixes: [], prefixes: [] };
|
||||
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,92 @@
|
||||
import { matchesKeyboardShortcutContext } from "../../keyboard-shortcuts";
|
||||
import { focusSurfaces } from "../focus-surfaces";
|
||||
import { overlays } from "../overlays";
|
||||
import { nativeKeyboardInput, type KeyboardSourceHandlers } from "./contract";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
import {
|
||||
nativeKeyboardInput,
|
||||
type KeyboardPolicyInput,
|
||||
type KeyboardSourceHandlers,
|
||||
} from "./contract";
|
||||
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeySource {
|
||||
subscribe(handler: (event: HardwareKeyboardEvent) => void): () => void;
|
||||
setOverlayKeys(keys: readonly string[]): void;
|
||||
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
|
||||
}
|
||||
export function attachHardwareKeyboardSource(
|
||||
source: HardwareKeySource,
|
||||
handlers: KeyboardSourceHandlers,
|
||||
): () => void {
|
||||
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
|
||||
updateKeys();
|
||||
const unsubscribeOverlays = overlays.subscribe(updateKeys);
|
||||
const unsubscribeHardware = source.subscribe((input) => {
|
||||
const event = { ...input, preventDefault() {}, stopPropagation() {} };
|
||||
if (input.phase === "up") {
|
||||
handlers.keyUp(event);
|
||||
return;
|
||||
|
||||
const DIGIT_CODES = Array.from({ length: 9 }, (_, index) => `Digit${index + 1}`);
|
||||
|
||||
/**
|
||||
* The shortcuts a focused text control would otherwise type or act on.
|
||||
*
|
||||
* The native window observes every press but cannot wait for the pipeline to
|
||||
* decide, so it has to claim these keys up front: without a claim, Shift+Tab
|
||||
* cycles the agent mode and the composer also inserts a tab. Cmd and Ctrl
|
||||
* combos do not type, and the platform's own editing combos stay with the text
|
||||
* control through `editable: false`. Unmodified keys belong to overlays and the
|
||||
* composer's Return.
|
||||
*/
|
||||
function claimedShortcuts(
|
||||
policy: KeyboardPolicyInput | null,
|
||||
commandCenterOpen: boolean,
|
||||
): ClaimedShortcut[] {
|
||||
if (!policy?.enabled) return [];
|
||||
const context = {
|
||||
isMac: policy.isMac,
|
||||
usesDesktopBindings: policy.usesDesktopBindings,
|
||||
commandCenterOpen,
|
||||
focusScope: focusSurfaces.resolve(commandCenterOpen, true),
|
||||
};
|
||||
const claimed = new Map<string, ClaimedShortcut>();
|
||||
for (const binding of policy.bindings) {
|
||||
const combo = binding.parsedChord[0];
|
||||
if (!combo || binding.preventDefault === false) continue;
|
||||
if (combo.meta || combo.ctrl || combo.mod || !(combo.shift || combo.alt)) continue;
|
||||
if (!matchesKeyboardShortcutContext(binding.when, context)) continue;
|
||||
const shift = combo.shift === true;
|
||||
const alt = combo.alt === true;
|
||||
for (const code of combo.code === "Digit" ? DIGIT_CODES : [combo.code]) {
|
||||
claimed.set(`${code}:${shift}:${alt}`, { code, shift, alt });
|
||||
}
|
||||
handlers.keyDown(
|
||||
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeHardware();
|
||||
unsubscribeOverlays();
|
||||
source.setOverlayKeys([]);
|
||||
}
|
||||
return [...claimed.values()];
|
||||
}
|
||||
|
||||
export function createHardwareKeyboardSource(source: HardwareKeySource) {
|
||||
let policy: KeyboardPolicyInput | null = null;
|
||||
let updateClaims = () => {};
|
||||
return {
|
||||
publishPolicy(next: KeyboardPolicyInput) {
|
||||
policy = next;
|
||||
updateClaims();
|
||||
},
|
||||
attach(handlers: KeyboardSourceHandlers): () => void {
|
||||
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
|
||||
updateClaims = () =>
|
||||
source.setClaimedShortcuts(claimedShortcuts(policy, handlers.commandCenterOpen()));
|
||||
updateKeys();
|
||||
updateClaims();
|
||||
const unsubscribeOverlays = overlays.subscribe(updateKeys);
|
||||
const unsubscribeFocus = focusSurfaces.subscribe(() => updateClaims());
|
||||
const unsubscribeCommandCenter = handlers.subscribeCommandCenter(() => updateClaims());
|
||||
const unsubscribeHardware = source.subscribe((input) => {
|
||||
const event = { ...input, preventDefault() {}, stopPropagation() {} };
|
||||
if (input.phase === "up") {
|
||||
handlers.keyUp(event);
|
||||
return;
|
||||
}
|
||||
handlers.keyDown(
|
||||
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeHardware();
|
||||
unsubscribeFocus();
|
||||
unsubscribeCommandCenter();
|
||||
unsubscribeOverlays();
|
||||
updateClaims = () => {};
|
||||
source.setOverlayKeys([]);
|
||||
source.setClaimedShortcuts([]);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,6 +226,20 @@ describe("keyboard-shortcuts", () => {
|
||||
action: "workspace.tab.navigate.relative",
|
||||
payload: { delta: 1 },
|
||||
},
|
||||
{
|
||||
name: "matches tab relative navigation on macOS desktop via Cmd+Shift+[",
|
||||
event: { key: "{", code: "BracketLeft", metaKey: true, shiftKey: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
action: "workspace.tab.navigate.relative",
|
||||
payload: { delta: -1 },
|
||||
},
|
||||
{
|
||||
name: "matches tab relative navigation on macOS desktop via Cmd+Shift+]",
|
||||
event: { key: "}", code: "BracketRight", metaKey: true, shiftKey: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
action: "workspace.tab.navigate.relative",
|
||||
payload: { delta: 1 },
|
||||
},
|
||||
{
|
||||
name: "matches Mod+T to open new tab",
|
||||
event: { key: "t", code: "KeyT", metaKey: true },
|
||||
@@ -479,6 +493,11 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "w", code: "KeyW", ctrlKey: true },
|
||||
context: { isMac: false, usesDesktopBindings: true, focusScope: "terminal" },
|
||||
},
|
||||
{
|
||||
name: "does not switch tabs with Alt+Shift+] on mac desktop (Cmd+Shift+] only)",
|
||||
event: { key: "’", code: "BracketRight", altKey: true, shiftKey: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
},
|
||||
{
|
||||
name: "does not match Ctrl+T on mac (Cmd only)",
|
||||
event: { key: "t", code: "KeyT", ctrlKey: true },
|
||||
@@ -1306,6 +1325,17 @@ describe("unassigned shortcuts", () => {
|
||||
expect(getBindingIdForAction("workspace-tab-new", desktopNonMac)).toBe(TAB_NEW_BINDING);
|
||||
});
|
||||
|
||||
it("advertises Cmd+Shift+brackets for tab navigation on macOS desktop", () => {
|
||||
const desktopMac = { isMac: true, usesDesktopBindings: true };
|
||||
|
||||
expect(getDefaultKeysForAction("workspace-tab-prev", desktopMac)).toEqual([
|
||||
["mod", "shift", "["],
|
||||
]);
|
||||
expect(getDefaultKeysForAction("workspace-tab-next", desktopMac)).toEqual([
|
||||
["mod", "shift", "]"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("still lists the default keys when nothing is overridden", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections(desktopNonMac);
|
||||
|
||||
|
||||
@@ -715,11 +715,37 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
|
||||
// --- Tab relative navigation ---
|
||||
// macOS desktop uses Cmd+Shift+brackets; the browser owns those keys, so
|
||||
// macOS web keeps Alt+Shift alongside every non-mac platform.
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-cmd-shift-left-mac",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Cmd+Shift+[",
|
||||
when: { mac: true, desktopBindings: true, commandCenter: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-tab-prev",
|
||||
section: "tabs-panes",
|
||||
label: "Previous tab",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-cmd-shift-right-mac",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Cmd+Shift+]",
|
||||
when: { mac: true, desktopBindings: true, commandCenter: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-tab-next",
|
||||
section: "tabs-panes",
|
||||
label: "Next tab",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-alt-shift-left",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Alt+Shift+[",
|
||||
when: { commandCenter: false },
|
||||
when: { mac: false, commandCenter: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-tab-prev",
|
||||
@@ -731,7 +757,31 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-navigate-relative-alt-shift-right",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Alt+Shift+]",
|
||||
when: { commandCenter: false },
|
||||
when: { mac: false, commandCenter: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-tab-next",
|
||||
section: "tabs-panes",
|
||||
label: "Next tab",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-alt-shift-left-mac-web",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Alt+Shift+[",
|
||||
when: { mac: true, desktopBindings: false, commandCenter: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-tab-prev",
|
||||
section: "tabs-panes",
|
||||
label: "Previous tab",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-alt-shift-right-mac-web",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
combo: "Alt+Shift+]",
|
||||
when: { mac: true, desktopBindings: false, commandCenter: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-tab-next",
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("DOM source adapter", () => {
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "j",
|
||||
|
||||
@@ -130,7 +130,7 @@ describe("hardware source adapter", () => {
|
||||
it.each([true, false])(
|
||||
"delivers hardware down/up and declared focus with mac=%s",
|
||||
async (isMac) => {
|
||||
const { attachHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { focusSurfaces } = await import("./internal/focus-surfaces");
|
||||
const actions: string[] = [];
|
||||
const releases: string[] = [];
|
||||
@@ -148,23 +148,22 @@ describe("hardware source adapter", () => {
|
||||
},
|
||||
overlays: createOverlayRegistry(),
|
||||
});
|
||||
const detach = attachHardwareKeyboardSource(
|
||||
{
|
||||
subscribe(handler) {
|
||||
events.add(handler);
|
||||
return () => {
|
||||
events.delete(handler);
|
||||
};
|
||||
},
|
||||
setOverlayKeys() {},
|
||||
const detach = createHardwareKeyboardSource({
|
||||
subscribe(handler) {
|
||||
events.add(handler);
|
||||
return () => {
|
||||
events.delete(handler);
|
||||
};
|
||||
},
|
||||
{
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp: (event) => releases.push(event.key),
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
},
|
||||
);
|
||||
setOverlayKeys() {},
|
||||
setClaimedShortcuts() {},
|
||||
}).attach({
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp: (event) => releases.push(event.key),
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
const down = {
|
||||
...keyboardEvent("k", "KeyK", isMac),
|
||||
isComposing: false,
|
||||
@@ -180,4 +179,94 @@ describe("hardware source adapter", () => {
|
||||
expect(events.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("claims the Shift and Option shortcuts a focused text control would otherwise type", async () => {
|
||||
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { focusSurfaces } = await import("./internal/focus-surfaces");
|
||||
let claimed: string[] = [];
|
||||
const source = createHardwareKeyboardSource({
|
||||
subscribe: () => () => {},
|
||||
setOverlayKeys() {},
|
||||
setClaimedShortcuts(shortcuts) {
|
||||
claimed = shortcuts
|
||||
.map(({ code, shift, alt }) => [alt && "Alt", shift && "Shift", code])
|
||||
.map((parts) => parts.filter(Boolean).join("+"))
|
||||
.sort();
|
||||
},
|
||||
});
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({}),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
let commandCenterOpen = false;
|
||||
const commandCenterListeners = new Set<() => void>();
|
||||
const detach = source.attach({
|
||||
keyDown() {},
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => commandCenterOpen,
|
||||
subscribeCommandCenter(listener) {
|
||||
commandCenterListeners.add(listener);
|
||||
return () => commandCenterListeners.delete(listener);
|
||||
},
|
||||
});
|
||||
const setCommandCenterOpen = (open: boolean) => {
|
||||
commandCenterOpen = open;
|
||||
for (const listener of commandCenterListeners) listener();
|
||||
};
|
||||
// iPad tab switching uses Cmd+Shift+[ / ], which no text control types.
|
||||
expect(claimed).toEqual([]);
|
||||
|
||||
const releaseComposer = focusSurfaces.declare("message-input");
|
||||
expect(claimed).toEqual(["Shift+Tab"]);
|
||||
setCommandCenterOpen(true);
|
||||
expect(claimed).toEqual([]);
|
||||
setCommandCenterOpen(false);
|
||||
expect(claimed).toEqual(["Shift+Tab"]);
|
||||
releaseComposer();
|
||||
expect(claimed).toEqual([]);
|
||||
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Alt+M" }),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
const releaseRebound = focusSurfaces.declare("message-input");
|
||||
expect(claimed).toEqual(["Alt+KeyM"]);
|
||||
releaseRebound();
|
||||
|
||||
// A chord's later step is only a shortcut while the chord is in progress;
|
||||
// claiming it always would stop the composer typing a capital A.
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Mod+J Shift+A" }),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
const releaseChord = focusSurfaces.declare("message-input");
|
||||
expect(claimed).toEqual([]);
|
||||
releaseChord();
|
||||
|
||||
const releaseFinal = focusSurfaces.declare("message-input");
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({}),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: false,
|
||||
});
|
||||
expect(claimed).toEqual([]);
|
||||
source.publishPolicy({
|
||||
bindings: buildEffectiveBindings({}),
|
||||
isMac: true,
|
||||
usesDesktopBindings: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(claimed).toEqual(["Shift+Tab"]);
|
||||
detach();
|
||||
expect(claimed).toEqual([]);
|
||||
releaseFinal();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ export function useKeyboardShortcuts({
|
||||
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
||||
const publishBrowserShortcutPolicy = useCallback(
|
||||
(chordState?: ChordState) => {
|
||||
publishKeyboardPolicy({ bindings, chordState, isMac, enabled });
|
||||
publishKeyboardPolicy({ bindings, chordState, isMac, usesDesktopBindings, enabled });
|
||||
},
|
||||
[bindings, enabled, isMac],
|
||||
);
|
||||
@@ -265,6 +265,10 @@ export function useKeyboardShortcuts({
|
||||
keyUp: handleKeyUp,
|
||||
blur: resetModifiers,
|
||||
commandCenterOpen: () => useKeyboardShortcutsStore.getState().commandCenterOpen,
|
||||
subscribeCommandCenter: (listener) =>
|
||||
useKeyboardShortcutsStore.subscribe((state, previous) => {
|
||||
if (state.commandCenterOpen !== previous.commandCenterOpen) listener();
|
||||
}),
|
||||
});
|
||||
return () => {
|
||||
detach();
|
||||
|
||||
@@ -134,6 +134,7 @@ describe("DOM source through the shared resolver", () => {
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
subscribeCommandCenter: () => () => {},
|
||||
});
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "j",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user