mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-13 03:45:11 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aba2a5f23 | ||
|
|
aead84bf0f | ||
|
|
7980ad008b | ||
|
|
7c35ad90b9 | ||
|
|
9eccb4722b | ||
|
|
536846eb60 | ||
|
|
cc16ac6ac4 | ||
|
|
fd9aebd622 | ||
|
|
21709812bb | ||
|
|
d426088c3f | ||
|
|
b5c25e5c10 | ||
|
|
525ee7f222 | ||
|
|
065c487a90 | ||
|
|
4df5307eaa | ||
|
|
aa7cabb455 | ||
|
|
9e607a3aa3 | ||
|
|
68818067f2 | ||
|
|
2240d4effe | ||
|
|
18c002cbf2 |
+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
|
||||
|
||||
@@ -85,6 +85,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
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
+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": [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -792,16 +792,6 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"Screenshots",
|
||||
"",
|
||||
// Nested HTML indentation must not turn the image markup into a code block.
|
||||
"<table>",
|
||||
" <tr>",
|
||||
' <td><img src="sibling.png" width="120" alt="table sibling"></td>',
|
||||
' <td><img src="../data/cache/hero.png" width="120" alt="table hero"></td>',
|
||||
" </tr>",
|
||||
"</table>",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
@@ -815,8 +805,6 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
|
||||
await expectMarkdownImageLoaded(page, "sibling");
|
||||
await expectMarkdownImageLoaded(page, "hero");
|
||||
await expectMarkdownImageLoaded(page, "table sibling");
|
||||
await expectMarkdownImageLoaded(page, "table hero");
|
||||
await visibleFilePane.screenshot({
|
||||
path: test.info().outputPath("markdown-relative-images.png"),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")}`;
|
||||
|
||||
@@ -9,12 +9,6 @@ const HTML = new RegExp(
|
||||
String.raw`(?<!\\)(?:<!--[\s\S]*?-->|<code\b[^>]*>[\s\S]*?<\/code\s*>|<summary\b[^>]*>[\s\S]*?<\/summary\s*>|${TAG})`,
|
||||
"gi",
|
||||
);
|
||||
// HTML tag-leading lines carry layout indentation, unlike embedded Markdown text.
|
||||
const HTML_OR_LINE_INDENT = new RegExp(
|
||||
String.raw`${HTML.source}|(?:^|(?<=[\r\n]))[ \t>]+(?=<)`,
|
||||
"gi",
|
||||
);
|
||||
const FLOW_CONTAINERS = new Set(["root", "blockquote", "listItem"]);
|
||||
const BLOCK_TAGS = new Set([
|
||||
"div",
|
||||
"p",
|
||||
@@ -36,86 +30,33 @@ const BLOCK_TAGS = new Set([
|
||||
]);
|
||||
const EMPHASIS: Readonly<Record<string, string>> = { b: "**", strong: "**", i: "*", em: "*" };
|
||||
|
||||
interface ContainerIndent {
|
||||
start: number;
|
||||
end: number;
|
||||
indent: number;
|
||||
}
|
||||
|
||||
function containerIndentAt(
|
||||
offset: number,
|
||||
outerIndent: number,
|
||||
containers: ContainerIndent[],
|
||||
): number {
|
||||
let indent = outerIndent;
|
||||
for (const container of containers) {
|
||||
if (offset >= container.start && offset < container.end)
|
||||
indent = Math.max(indent, container.indent);
|
||||
}
|
||||
return indent;
|
||||
}
|
||||
|
||||
/** Convert comment HTML to Markdown while preserving the original spelling of code. */
|
||||
export function normalizeMarkdownHtml(source: string): string {
|
||||
if (!source.includes("<")) return source;
|
||||
// `indent` is the container prefix width of an HTML block's lines; inline HTML has none.
|
||||
const htmlRanges: { start: number; end: number; indent?: number }[] = [];
|
||||
function findHtml(node: Nodes, block: boolean) {
|
||||
const htmlRanges: { start: number; end: number }[] = [];
|
||||
function findHtml(node: Nodes) {
|
||||
if (node.type === "html" && node.position) {
|
||||
htmlRanges.push({
|
||||
start: node.position.start.offset!,
|
||||
end: node.position.end.offset!,
|
||||
// Whitespace inside <pre> is content.
|
||||
indent:
|
||||
block && !/^\s*<pre\b/i.test(node.value) ? node.position.start.column - 1 : undefined,
|
||||
});
|
||||
} else if ("children" in node)
|
||||
node.children.forEach((child) => findHtml(child, FLOW_CONTAINERS.has(node.type)));
|
||||
htmlRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
|
||||
} else if ("children" in node) node.children.forEach(findHtml);
|
||||
}
|
||||
findHtml(fromMarkdown(source), false);
|
||||
findHtml(fromMarkdown(source));
|
||||
const codeRanges: { start: number; end: number }[] = [];
|
||||
const markdownContainers: ContainerIndent[] = [];
|
||||
// Disable HTML parsing so code inside <details> remains visible to the Markdown parser.
|
||||
const tree = fromMarkdown(source, {
|
||||
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }],
|
||||
});
|
||||
function readMarkdownContext(node: Nodes) {
|
||||
function protectCode(node: Nodes) {
|
||||
if (node.type === "code" || node.type === "inlineCode") {
|
||||
const start = node.position?.start.offset;
|
||||
const end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) codeRanges.push({ start, end });
|
||||
}
|
||||
if ((node.type === "listItem" || node.type === "blockquote") && node.position) {
|
||||
const position = node.position;
|
||||
const content = node.children[0]?.position;
|
||||
// Outer containers already belong to the HTML node. Only discover Markdown
|
||||
// containers that actually begin inside that HTML block, not earlier lists
|
||||
// that the HTML-disabled parse might treat as lazy continuations.
|
||||
const html = htmlRanges.find(
|
||||
(range) =>
|
||||
range.indent !== undefined &&
|
||||
position.start.offset! >= range.start &&
|
||||
position.start.offset! < range.end,
|
||||
);
|
||||
if (html && content)
|
||||
markdownContainers.push({
|
||||
start: Math.max(html.start, position.start.offset! - position.start.column + 1),
|
||||
end: Math.min(html.end, position.end.offset!),
|
||||
indent: content.start.column - 1,
|
||||
});
|
||||
}
|
||||
if ("children" in node) node.children.forEach(readMarkdownContext);
|
||||
} else if ("children" in node) node.children.forEach(protectCode);
|
||||
}
|
||||
readMarkdownContext(tree);
|
||||
protectCode(tree);
|
||||
const links: (string | undefined)[] = [];
|
||||
let previousTag: { name: string; end: number } | null = null;
|
||||
let preDepth = 0;
|
||||
const convert = (raw: string, offset: number, indent: number): string => {
|
||||
const convert = (raw: string, offset: number): string => {
|
||||
if (codeRanges.some(({ start, end }) => offset >= start && offset < end)) return raw;
|
||||
// Removing tags must not leave layout spaces that start Markdown code.
|
||||
// Preserve the quote/list prefix and all indentation on Markdown text lines.
|
||||
if (!raw.startsWith("<"))
|
||||
return preDepth > 0 ? raw : raw.slice(0, indent) + raw.slice(indent).replace(/^[ \t]+/, "");
|
||||
if (raw.startsWith("<!--")) return "";
|
||||
const summary = /^<summary\b[^>]*>([\s\S]*?)<\/summary\s*>$/i.exec(raw);
|
||||
if (summary) {
|
||||
@@ -136,7 +77,6 @@ export function normalizeMarkdownHtml(source: string): string {
|
||||
);
|
||||
const closing = raw.startsWith("</");
|
||||
const name = /^<\/?([\w-]+)/.exec(raw)?.[1].toLowerCase() ?? "";
|
||||
if (name === "pre") preDepth = Math.max(0, preDepth + (closing ? -1 : 1));
|
||||
// Each gap is inspected once, rather than searching the complete prefix
|
||||
// for a preceding tag on every table row or nested block.
|
||||
const startsAfterBoundary = previousTag
|
||||
@@ -164,13 +104,7 @@ export function normalizeMarkdownHtml(source: string): string {
|
||||
const end = paired ? Math.max(range.end, range.start + paired[0].length) : range.end;
|
||||
result += source
|
||||
.slice(range.start, end)
|
||||
.replace(range.indent === undefined ? HTML : HTML_OR_LINE_INDENT, (raw, offset) =>
|
||||
convert(
|
||||
raw,
|
||||
range.start + offset,
|
||||
containerIndentAt(range.start + offset, range.indent ?? 0, markdownContainers),
|
||||
),
|
||||
);
|
||||
.replace(HTML, (raw, offset) => convert(raw, range.start + offset));
|
||||
cursor = end;
|
||||
}
|
||||
return result + source.slice(cursor);
|
||||
|
||||
@@ -779,99 +779,6 @@ describe("GitHub comment HTML normalization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("indented HTML blocks", () => {
|
||||
// A README screenshot row: GitHub ignores the indentation of the nested tags.
|
||||
const gallery = [
|
||||
"<table>",
|
||||
" <tr>",
|
||||
' <td><img src="shots/one.png" width="120" alt="One"></td>',
|
||||
' <td><img src="shots/two.png" width="120" alt="Two"></td>',
|
||||
" </tr>",
|
||||
"</table>",
|
||||
];
|
||||
const urls = new Map([
|
||||
["shots/one.png", "blob:one"],
|
||||
["shots/two.png", "blob:two"],
|
||||
]);
|
||||
function blocks(node: ASTNode): unknown {
|
||||
if (node.type === "Paragraph" || node.type === "CodeBlock") {
|
||||
const image = node.children?.find((child) => child.type === "Image");
|
||||
return image ? `Image ${image.attributes?.url}` : node.type;
|
||||
}
|
||||
return [node.type, ...(node.children ?? []).map(blocks)];
|
||||
}
|
||||
|
||||
it.each([
|
||||
["a standalone table", gallery.join("\n"), ["Document", "Image blob:one", "Image blob:two"]],
|
||||
[
|
||||
"after a list",
|
||||
["- Feature", "", ...gallery].join("\n"),
|
||||
[
|
||||
"Document",
|
||||
["UnorderedList", ["ListItem", "Paragraph"]],
|
||||
"Image blob:one",
|
||||
"Image blob:two",
|
||||
],
|
||||
],
|
||||
[
|
||||
"the document",
|
||||
["Intro", "", ...gallery].join("\n"),
|
||||
["Document", "Paragraph", "Image blob:one", "Image blob:two"],
|
||||
],
|
||||
[
|
||||
"a list item",
|
||||
["- Shots", ...gallery.map((line) => ` ${line}`), "- Next"].join("\n"),
|
||||
[
|
||||
"Document",
|
||||
[
|
||||
"UnorderedList",
|
||||
["ListItem", "Paragraph", "Image blob:one", "Image blob:two"],
|
||||
["ListItem", "Paragraph"],
|
||||
],
|
||||
],
|
||||
],
|
||||
])("renders the images of an indented HTML table in %s", async (_name, source, expected) => {
|
||||
const prepared = prepareMarkdownSource(source);
|
||||
expect(prepared.images.map((image) => image.source)).toEqual([
|
||||
"shots/one.png",
|
||||
"shots/two.png",
|
||||
]);
|
||||
const tree = await parseMarkdown(prepared.render(urls), {
|
||||
hardSoftBreaks: true,
|
||||
latexMath: false,
|
||||
});
|
||||
expect(blocks(tree)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("keeps HTML table images inside their blockquote", async () => {
|
||||
const prepared = prepareMarkdownSource(gallery.map((line) => `> ${line}`).join("\n"));
|
||||
expect(prepared.images.map((image) => image.source)).toEqual([
|
||||
"shots/one.png",
|
||||
"shots/two.png",
|
||||
]);
|
||||
const tree = await parseMarkdown(prepared.render(urls), {
|
||||
hardSoftBreaks: true,
|
||||
latexMath: false,
|
||||
});
|
||||
expect(tree.children?.every((node) => node.type === "Blockquote")).toBe(true);
|
||||
expect(tree.children?.flatMap((node) => node.children ?? []).map(blocks)).toEqual([
|
||||
"Image blob:one",
|
||||
"Image blob:two",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a fence",
|
||||
"<details><summary>Code</summary>\n```js\n indented();\n```\n</details>",
|
||||
"**Code**\n\n\n```js\n indented();\n```\n",
|
||||
],
|
||||
["a pre block", "<pre>\n\n kept\n</pre>", "\n\n kept\n"],
|
||||
])("keeps the indentation of %s inside HTML", (_name, source, expected) => {
|
||||
expect(prepareMarkdownSource(source).render()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves backslashes and decodes entities inside HTML code", () => {
|
||||
const source = String.raw`<code>\*literal\* <img></code>`;
|
||||
expect(prepareMarkdownSource(source).render()).toBe("`" + String.raw`\*literal\* <img>` + "`");
|
||||
@@ -1189,44 +1096,3 @@ it.each([false, true])(
|
||||
expect(markdownStyle.codeBlock?.fontSize).toBe(theme.fontSize.code);
|
||||
},
|
||||
);
|
||||
|
||||
describe("HTML indentation preserves Markdown content", () => {
|
||||
it("keeps nested Markdown list depth inside details", () => {
|
||||
const source = "<details>\n<summary>Steps</summary>\n- Parent\n - Child\n</details>";
|
||||
const tree = fromMarkdown(normalizeMarkdownHtml(source));
|
||||
const list = tree.children.find((node) => node.type === "list");
|
||||
expect(list?.children).toHaveLength(1);
|
||||
expect(list?.children[0].children[1]?.type).toBe("list");
|
||||
});
|
||||
|
||||
it("keeps nested preformatted text indentation", () => {
|
||||
const source = "<div><pre>\n first\n second\n</pre></div>";
|
||||
const tree = fromMarkdown(normalizeMarkdownHtml(source));
|
||||
expect(tree.children).toMatchObject([{ type: "code", value: "first\n second" }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps code-tag lines preformatted inside nested HTML", () => {
|
||||
const source = "<div><pre>\n <code>first</code>\n second\n</pre></div>";
|
||||
const tree = fromMarkdown(normalizeMarkdownHtml(source));
|
||||
expect(tree.children).toMatchObject([{ type: "code", value: "`first`\n second" }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an HTML image", ' <img src="a.png" alt="Shot">', "Image"],
|
||||
["quoted HTML text", " > <b>quoted</b>", "Blockquote"],
|
||||
])("keeps %s in its Markdown list item inside details", async (_name, line, expectedType) => {
|
||||
const prepared = prepareMarkdownSource(
|
||||
["<details>", "<summary>Steps</summary>", "- Parent", line, "</details>"].join("\n"),
|
||||
);
|
||||
const tree = await parseMarkdown(prepared.render(new Map([["a.png", "blob:shot"]])), {
|
||||
hardSoftBreaks: true,
|
||||
latexMath: false,
|
||||
});
|
||||
const list = tree.children?.find((node) => node.type === "UnorderedList");
|
||||
expect(list?.children).toHaveLength(1);
|
||||
function descendants(node: ASTNode): ASTNode[] {
|
||||
return [node, ...(node.children ?? []).flatMap(descendants)];
|
||||
}
|
||||
expect(list?.children?.flatMap(descendants).map((node) => node.type)).toContain(expectedType);
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import type {
|
||||
CreateAgentRequestOptions,
|
||||
CreateWorkspaceRequestOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
@@ -126,7 +123,6 @@ import {
|
||||
} from "./workspace/terminals/state";
|
||||
import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff";
|
||||
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
|
||||
import { retriedAgentRequest } from "./new-workspace/retry-agent-request";
|
||||
|
||||
function useIsNewWorkspaceDraftHandoffActive(input: {
|
||||
draftId: string | undefined;
|
||||
@@ -243,7 +239,6 @@ interface SubmitDraftInput {
|
||||
clearConsumedDraft: () => void;
|
||||
agentCreation?: {
|
||||
result: Promise<AgentSnapshotPayload>;
|
||||
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
|
||||
};
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
@@ -471,13 +466,13 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
||||
images: images?.length ? images : undefined,
|
||||
attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined,
|
||||
};
|
||||
const execute = async (requestedAgent = initialAgent): Promise<AgentSnapshotPayload> => {
|
||||
const execute = async (): Promise<AgentSnapshotPayload> => {
|
||||
const { agent } = await ensureWorkspace({
|
||||
cwd,
|
||||
prompt: text,
|
||||
attachments: workspaceNamingAttachments,
|
||||
withInitialAgent: true,
|
||||
agent: requestedAgent,
|
||||
agent: initialAgent,
|
||||
onEvent: (snapshot) => {
|
||||
if (!snapshot.workspace || navigated) return;
|
||||
navigated = true;
|
||||
@@ -516,11 +511,8 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
||||
if (!agent) throw new Error("Workspace creation returned no agent");
|
||||
return agent;
|
||||
};
|
||||
const agentCreation = {
|
||||
result: Promise.resolve().then(() => execute()),
|
||||
retry: (request: CreateAgentRequestOptions) =>
|
||||
execute(retriedAgentRequest(initialAgent, request, cwd)),
|
||||
};
|
||||
// A retry after a failed agent start belongs to the ready workspace's draft, not to this creation.
|
||||
const agentCreation = { result: Promise.resolve().then(() => execute()) };
|
||||
await agentCreation.result;
|
||||
if (outcome === "background") clearConsumedDraft();
|
||||
return outcome;
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { retriedAgentRequest, type WorkspaceAgentRequest } from "./retry-agent-request";
|
||||
|
||||
const initial: WorkspaceAgentRequest = {
|
||||
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
|
||||
metadata: { "issues.key": "PAS-1" },
|
||||
initialPrompt: "first try",
|
||||
clientMessageId: "draft_1:initial-message",
|
||||
};
|
||||
|
||||
describe("retriedAgentRequest", () => {
|
||||
it("creates with the metadata the draft resubmitted", () => {
|
||||
const retried = retriedAgentRequest(
|
||||
initial,
|
||||
{
|
||||
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
|
||||
initialPrompt: "second try",
|
||||
metadata: { "issues.key": "PAS-2" },
|
||||
},
|
||||
"/repo",
|
||||
);
|
||||
expect(retried.metadata).toEqual({ "issues.key": "PAS-2" });
|
||||
expect(retried.initialPrompt).toBe("second try");
|
||||
expect(retried.clientMessageId).toBe("draft_1:initial-message");
|
||||
});
|
||||
|
||||
it("creates with no metadata once the draft cleared it", () => {
|
||||
const retried = retriedAgentRequest(
|
||||
initial,
|
||||
{ config: { provider: "codex", cwd: "/repo", model: "gpt-6" }, initialPrompt: "second try" },
|
||||
"/repo",
|
||||
);
|
||||
expect("metadata" in retried).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the first attempt's directory over the resubmitted config", () => {
|
||||
const retried = retriedAgentRequest(
|
||||
initial,
|
||||
{
|
||||
config: { provider: "codex", cwd: "/elsewhere", model: "gpt-6-mini" },
|
||||
initialPrompt: "second try",
|
||||
},
|
||||
"/repo",
|
||||
);
|
||||
expect(retried.config).toEqual({ provider: "codex", cwd: "/repo", model: "gpt-6-mini" });
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import type {
|
||||
CreateAgentRequestOptions,
|
||||
CreateWorkspaceRequestOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
export type WorkspaceAgentRequest = NonNullable<CreateWorkspaceRequestOptions["agent"]>;
|
||||
|
||||
/**
|
||||
* The agent a retried creation asks for: everything the draft resubmitted (config, prompt,
|
||||
* images, attachments and metadata, cleared included) on the first attempt's identity, so a
|
||||
* retry creates what the composer shows now, not what it showed when creation first failed.
|
||||
*/
|
||||
export function retriedAgentRequest(
|
||||
initial: WorkspaceAgentRequest,
|
||||
request: CreateAgentRequestOptions,
|
||||
cwd: string,
|
||||
): WorkspaceAgentRequest {
|
||||
const { metadata: _replaced, ...identity } = initial;
|
||||
return {
|
||||
...identity,
|
||||
config: { ...request.config!, cwd },
|
||||
initialPrompt: request.initialPrompt ?? "",
|
||||
clientMessageId: initial.clientMessageId,
|
||||
images: request.images,
|
||||
attachments: request.attachments,
|
||||
...(request.metadata ? { metadata: request.metadata } : {}),
|
||||
};
|
||||
}
|
||||
@@ -17,22 +17,16 @@ import {
|
||||
selectWorkspaceExists,
|
||||
selectWorkspaceFields,
|
||||
selectWorkspaceKeys,
|
||||
selectWorkspaceStatusesForBadges,
|
||||
workspaceEqualityFns,
|
||||
type WorkspaceStructure,
|
||||
} from "./selectors";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
|
||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
|
||||
// These are the ONLY supported ways to read workspaces from the session store.
|
||||
// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object,
|
||||
// or the sessions dict — it breaks re-render isolation.
|
||||
|
||||
export type {
|
||||
DesktopBadgeWorkspaceStatus,
|
||||
WorkspaceStructure,
|
||||
WorkspaceStructureProject,
|
||||
} from "./selectors";
|
||||
export type { WorkspaceStructure, WorkspaceStructureProject } from "./selectors";
|
||||
|
||||
export function useWorkspace(
|
||||
serverId: string | null,
|
||||
@@ -163,11 +157,3 @@ export function useHasWorkspaces(serverId: string | null): boolean {
|
||||
workspaceEqualityFns.identity,
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => selectWorkspaceStatusesForBadges(state),
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
selectWorkspaceDirectory,
|
||||
selectWorkspaceFields,
|
||||
selectWorkspaceKeys,
|
||||
selectWorkspaceStatusesForBadges,
|
||||
selectWorkspaceStructureProjects,
|
||||
workspaceEqualityFns,
|
||||
type SidebarOrderSnapshot,
|
||||
@@ -589,36 +588,6 @@ describe("selectHasWorkspaces", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectWorkspaceStatusesForBadges", () => {
|
||||
it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => {
|
||||
const workspaceA = createWorkspace({ id: "workspace-a", status: "done" });
|
||||
const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" });
|
||||
initializeWorkspaces([workspaceA, workspaceB]);
|
||||
|
||||
const tracked = trackSelector(
|
||||
useSessionStore,
|
||||
(state) => selectWorkspaceStatusesForBadges(state),
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
const before = tracked.current;
|
||||
expect(before).toEqual(["done", "attention"]);
|
||||
|
||||
useSessionStore
|
||||
.getState()
|
||||
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]);
|
||||
expect(tracked.current).toBe(before);
|
||||
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]);
|
||||
expect(tracked.current).toBe(before);
|
||||
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]);
|
||||
expect(tracked.current).not.toBe(before);
|
||||
expect(tracked.current).toEqual(["failed", "attention"]);
|
||||
|
||||
tracked.stop();
|
||||
});
|
||||
});
|
||||
|
||||
it("a prompt changes only its project's structure and output preserves both project identities", () => {
|
||||
const a = { ...createWorkspace({ id: "a", projectId: "p" }), createdAt: new Date("2026-01-01") };
|
||||
const b = { ...createWorkspace({ id: "b", projectId: "p" }), createdAt: new Date("2026-02-01") };
|
||||
|
||||
@@ -5,11 +5,9 @@ import {
|
||||
type WorkspaceStructure,
|
||||
type WorkspaceStructureProject,
|
||||
} from "@/projects/workspace-structure";
|
||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
import type { ProjectDescriptor, WorkspaceDescriptor } from "../session-store";
|
||||
|
||||
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
|
||||
export interface SessionsSnapshot {
|
||||
@@ -321,15 +319,3 @@ export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string |
|
||||
}
|
||||
return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function selectWorkspaceStatusesForBadges(
|
||||
state: SessionsSnapshot,
|
||||
): DesktopBadgeWorkspaceStatus[] {
|
||||
const statuses: DesktopBadgeWorkspaceStatus[] = [];
|
||||
for (const session of Object.values(state.sessions)) {
|
||||
for (const workspace of session.workspaces.values()) {
|
||||
statuses.push(workspace.status);
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
@@ -45,27 +45,3 @@ test("the production app consumes a draft once and leaves other drafts pending",
|
||||
expect(store.getState().consumePending(submission)).toBeNull();
|
||||
expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]);
|
||||
});
|
||||
|
||||
test("consuming draft presentation retains its running creation through remount and retry", () => {
|
||||
const store = loadProductionStore();
|
||||
const result = new Promise<never>(() => {});
|
||||
const creation = { result, retry: () => result };
|
||||
const submission = {
|
||||
serverId: "server",
|
||||
workspaceId: "workspace",
|
||||
draftId: "draft-one",
|
||||
text: "hello",
|
||||
attachments: [],
|
||||
cwd: "/repo",
|
||||
provider: "codex",
|
||||
clientMessageId: "message",
|
||||
timestamp: 1,
|
||||
agentCreation: creation,
|
||||
};
|
||||
store.getState().setPending(submission);
|
||||
store.getState().consumePending(submission);
|
||||
expect(store.getState().pendingByDraftId[submission.draftId]).toBeUndefined();
|
||||
expect(store.getState().creationByDraftId[submission.draftId]).toBe(creation);
|
||||
store.getState().clearDraftSetup({ draftId: submission.draftId });
|
||||
expect(store.getState().creationByDraftId[submission.draftId]).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { create } from "zustand";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
@@ -9,7 +8,6 @@ export interface PendingWorkspaceDraftSubmission {
|
||||
/** Already-running creation. Mounting the draft only observes its result. */
|
||||
agentCreation?: {
|
||||
result: Promise<AgentSnapshotPayload>;
|
||||
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
|
||||
};
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -35,7 +33,6 @@ export interface PendingWorkspaceDraftSetup {
|
||||
interface WorkspaceDraftSubmissionState {
|
||||
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
|
||||
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
|
||||
creationByDraftId: Record<string, NonNullable<PendingWorkspaceDraftSubmission["agentCreation"]>>;
|
||||
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
|
||||
setDraftSetup: (input: {
|
||||
draftId: string;
|
||||
@@ -69,12 +66,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
|
||||
(set, get) => ({
|
||||
pendingByDraftId: {},
|
||||
setupByDraftId: {},
|
||||
creationByDraftId: {},
|
||||
setPending: (submission) =>
|
||||
set((state) => ({
|
||||
creationByDraftId: submission.agentCreation
|
||||
? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation }
|
||||
: state.creationByDraftId,
|
||||
pendingByDraftId: {
|
||||
...state.pendingByDraftId,
|
||||
[submission.draftId]: submission,
|
||||
@@ -94,10 +87,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
|
||||
const normalizedDraftId = normalizeDraftId(draftId);
|
||||
if (!normalizedDraftId) return;
|
||||
set((state) => {
|
||||
const creationByDraftId = { ...state.creationByDraftId };
|
||||
delete creationByDraftId[normalizedDraftId];
|
||||
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
|
||||
return { setupByDraftId, creationByDraftId };
|
||||
return { setupByDraftId };
|
||||
});
|
||||
},
|
||||
consumePending: (input) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { getAgentTabsNeedingOpenLabel, selectOpenTabAgents } from "./open-tab-labels";
|
||||
|
||||
const label = "paseo.open-agent-tab.client-1";
|
||||
|
||||
@@ -51,3 +52,54 @@ describe("getAgentTabsNeedingOpenLabel", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectOpenTabAgents", () => {
|
||||
function stateWith(agents: Agent[]) {
|
||||
return {
|
||||
sessions: {
|
||||
srv: { agents: new Map(agents.map((entry) => [entry.id, entry])), agentDetails: new Map() },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("stays equal when only an agent without an open tab changes", () => {
|
||||
const open = agent({ id: "open" });
|
||||
const before = selectOpenTabAgents(stateWith([open, agent({ id: "elsewhere" })]), {
|
||||
serverId: "srv",
|
||||
agentIds: ["open"],
|
||||
});
|
||||
const after = selectOpenTabAgents(stateWith([open, agent({ id: "elsewhere" })]), {
|
||||
serverId: "srv",
|
||||
agentIds: ["open"],
|
||||
});
|
||||
|
||||
expect(shallow(before, after)).toBe(true);
|
||||
});
|
||||
|
||||
it("changes when an agent with an open tab changes", () => {
|
||||
const elsewhere = agent({ id: "elsewhere" });
|
||||
const before = selectOpenTabAgents(stateWith([agent({ id: "open" }), elsewhere]), {
|
||||
serverId: "srv",
|
||||
agentIds: ["open"],
|
||||
});
|
||||
const after = selectOpenTabAgents(stateWith([agent({ id: "open" }), elsewhere]), {
|
||||
serverId: "srv",
|
||||
agentIds: ["open"],
|
||||
});
|
||||
|
||||
expect(shallow(before, after)).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to agent details and returns null for unknown agents", () => {
|
||||
const detail = agent({ id: "detail" });
|
||||
const state = {
|
||||
sessions: {
|
||||
srv: { agents: new Map<string, Agent>(), agentDetails: new Map([["detail", detail]]) },
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
selectOpenTabAgents(state, { serverId: "srv", agentIds: ["detail", "missing"] }),
|
||||
).toEqual([detail, null]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,3 +23,24 @@ export function getAgentTabsNeedingOpenLabel(input: {
|
||||
}
|
||||
return [...agentIds];
|
||||
}
|
||||
|
||||
interface OpenTabAgentSource {
|
||||
sessions: Record<
|
||||
string,
|
||||
{ agents: ReadonlyMap<string, Agent>; agentDetails: ReadonlyMap<string, Agent> } | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agents behind the given open tabs, in tab order. Compared element by element, this changes
|
||||
* only when one of those agents changes, not on updates to other agents on the host.
|
||||
*/
|
||||
export function selectOpenTabAgents(
|
||||
state: OpenTabAgentSource,
|
||||
input: { serverId: string; agentIds: readonly string[] },
|
||||
): (Agent | null)[] {
|
||||
const session = state.sessions[input.serverId];
|
||||
return input.agentIds.map(
|
||||
(agentId) => session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
|
||||
import { getAgentTabsNeedingOpenLabel, selectOpenTabAgents } from "./open-tab-labels";
|
||||
|
||||
function increment(value: number): number {
|
||||
return value + 1;
|
||||
@@ -16,9 +17,16 @@ export function useOpenAgentTabLabels(input: {
|
||||
tabs: WorkspaceTab[];
|
||||
enabled: boolean;
|
||||
}): void {
|
||||
const agents = useSessionStore((state) => state.sessions[input.serverId]?.agents ?? null);
|
||||
const agentDetails = useSessionStore(
|
||||
(state) => state.sessions[input.serverId]?.agentDetails ?? null,
|
||||
const openAgentIds = useMemo(
|
||||
() => input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
|
||||
[input.tabs],
|
||||
);
|
||||
// Only the agents with open tabs can need a label. Subscribing to the whole agent map would
|
||||
// re-render the workspace screen on every update to any agent on the host.
|
||||
const openAgents = useSessionStore(
|
||||
useShallow((state) =>
|
||||
selectOpenTabAgents(state, { serverId: input.serverId, agentIds: openAgentIds }),
|
||||
),
|
||||
);
|
||||
const pendingAgentIdsRef = useRef(new Set<string>());
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -39,15 +47,13 @@ export function useOpenAgentTabLabels(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
const openAgentIds = new Set(
|
||||
input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
|
||||
);
|
||||
const agentsById = new Map(openAgentIds.map((agentId, index) => [agentId, openAgents[index]]));
|
||||
for (const agentId of pendingAgentIdsRef.current) {
|
||||
if (!openAgentIds.has(agentId)) {
|
||||
if (!agentsById.has(agentId)) {
|
||||
pendingAgentIdsRef.current.delete(agentId);
|
||||
}
|
||||
}
|
||||
if (openAgentIds.size === 0) {
|
||||
if (agentsById.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,7 +63,7 @@ export function useOpenAgentTabLabels(input: {
|
||||
const label = getOpenAgentTabLabel(clientId);
|
||||
const agentIds = getAgentTabsNeedingOpenLabel({
|
||||
tabs: input.tabs,
|
||||
getAgent: (agentId) => agents?.get(agentId) ?? agentDetails?.get(agentId),
|
||||
getAgent: (agentId) => agentsById.get(agentId),
|
||||
label,
|
||||
pendingAgentIds: pendingAgentIdsRef.current,
|
||||
});
|
||||
@@ -82,5 +88,5 @@ export function useOpenAgentTabLabels(input: {
|
||||
console.warn("[OpenAgentTabLabels] Failed to resolve client ID", { error });
|
||||
}
|
||||
})();
|
||||
}, [agentDetails, agents, input.client, input.enabled, input.tabs, retryVersion]);
|
||||
}, [input.client, input.enabled, input.tabs, openAgentIds, openAgents, retryVersion]);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TERMINAL_INPUT_CONTEXT_MENU_HIDDEN,
|
||||
TERMINAL_INPUT_HITBOX_SIZE,
|
||||
} from "./terminal-input.native";
|
||||
import { forwardNativeTerminalKey, type NativeTerminalKeyEvent } from "./terminal-key-events";
|
||||
import { createNativeTerminalKeyEvent, resolveHardwareTerminalKey } from "./terminal-key-events";
|
||||
|
||||
describe("native terminal typed input", () => {
|
||||
function inputData(change: { data: string; shouldClear: boolean }): string {
|
||||
@@ -149,25 +149,18 @@ describe("native terminal typed input", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards native arrow key events to the terminal key path", () => {
|
||||
const events: NativeTerminalKeyEvent[] = [];
|
||||
|
||||
forwardNativeTerminalKey({
|
||||
key: "ArrowUp",
|
||||
onTerminalKey: (event) => {
|
||||
events.push(event);
|
||||
},
|
||||
it("identifies hardware arrows by code and keeps their modifiers", () => {
|
||||
// UIKit leaves `key` as a sentinel for Left/Right, so the code leads.
|
||||
expect(resolveHardwareTerminalKey({ key: "\uF702", code: "ArrowLeft" })).toBe("ArrowLeft");
|
||||
expect(resolveHardwareTerminalKey({ key: "ArrowUp", code: "" })).toBe("ArrowUp");
|
||||
expect(resolveHardwareTerminalKey({ key: "a", code: "KeyA" })).toBeNull();
|
||||
expect(createNativeTerminalKeyEvent("ArrowDown", { shift: true, alt: true })).toEqual({
|
||||
key: "ArrowDown",
|
||||
ctrl: false,
|
||||
shift: true,
|
||||
alt: true,
|
||||
meta: false,
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
key: "ArrowUp",
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the hidden input from owning terminal long-press selection", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import {
|
||||
Platform,
|
||||
StyleSheet,
|
||||
type NativeSyntheticEvent,
|
||||
type StyleProp,
|
||||
@@ -10,10 +11,19 @@ import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
} from "@/components/ui/text-input";
|
||||
import { resolveNativeTerminalKey, type NativeTerminalKey } from "./terminal-key-events";
|
||||
import { hasActiveOverlay, subscribeHardwareKeyboard } from "@/keyboard";
|
||||
import {
|
||||
createNativeTerminalKeyEvent,
|
||||
resolveHardwareTerminalKey,
|
||||
resolveNativeTerminalKey,
|
||||
type NativeTerminalKey,
|
||||
type NativeTerminalKeyEvent,
|
||||
} from "./terminal-key-events";
|
||||
|
||||
export const TERMINAL_INPUT_CONTEXT_MENU_HIDDEN = true;
|
||||
export const TERMINAL_INPUT_HITBOX_SIZE = 1;
|
||||
/** Matched by the Android hardware keyboard module to claim arrow keys for this input. */
|
||||
export const TERMINAL_INPUT_NATIVE_ID = "paseo-terminal-input";
|
||||
|
||||
export interface TerminalTextInputChange {
|
||||
data: string;
|
||||
@@ -39,7 +49,7 @@ interface TerminalInputProps {
|
||||
isKeyboardVisible: boolean;
|
||||
onFocus?: () => void;
|
||||
onInput?: (data: string) => void;
|
||||
onTerminalKey?: (key: NativeTerminalKey) => void;
|
||||
onTerminalKey?: (event: NativeTerminalKeyEvent) => void;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
@@ -187,6 +197,8 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const isFocusedRef = useRef(false);
|
||||
const pendingFocusFrameRef = useRef<number | null>(null);
|
||||
const onTerminalKeyRef = useRef(onTerminalKey);
|
||||
onTerminalKeyRef.current = onTerminalKey;
|
||||
const inputState = useMemo(() => createTerminalTextInputState(), []);
|
||||
const inputStyle = useMemo(() => [styles.input, style], [style]);
|
||||
|
||||
@@ -250,6 +262,27 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
|
||||
useEffect(() => clearPendingFocus, [clearPendingFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
// Physical arrows arrive through the native key stream, never through
|
||||
// TextInput.onKeyPress (RN builds that event from the IME connection).
|
||||
// The native module claims them for this input so the hidden buffer
|
||||
// cannot move its caret, then re-emits them here for the PTY.
|
||||
if (Platform.OS !== "android") return;
|
||||
return subscribeHardwareKeyboard((event) => {
|
||||
if (!isFocusedRef.current || event.phase !== "down" || event.isComposing) return;
|
||||
if (hasActiveOverlay()) return;
|
||||
const key = resolveHardwareTerminalKey(event);
|
||||
if (!key) return;
|
||||
onTerminalKeyRef.current?.({
|
||||
key,
|
||||
ctrl: event.ctrlKey,
|
||||
shift: event.shiftKey,
|
||||
alt: event.altKey,
|
||||
meta: event.metaKey,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -293,7 +326,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
const change = inputState.receiveKeyPress(event.nativeEvent.key);
|
||||
if (change.key) {
|
||||
onTerminalKey?.(change.key);
|
||||
onTerminalKey?.(createNativeTerminalKeyEvent(change.key));
|
||||
}
|
||||
if (change.data.length > 0) {
|
||||
onInput?.(change.data);
|
||||
@@ -321,6 +354,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
blurOnSubmit={false}
|
||||
importantForAutofill="no"
|
||||
multiline={true}
|
||||
nativeID={TERMINAL_INPUT_NATIVE_ID}
|
||||
onChangeText={handleChangeText}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
|
||||
@@ -22,19 +22,26 @@ export function resolveNativeTerminalKey(key: string): NativeTerminalKey | null
|
||||
}
|
||||
}
|
||||
|
||||
export function createNativeTerminalKeyEvent(key: NativeTerminalKey): NativeTerminalKeyEvent {
|
||||
return {
|
||||
key,
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
};
|
||||
/**
|
||||
* Physical keyboards report the DOM `code`. UIKit leaves `key` as a sentinel
|
||||
* for Left/Right, so the code is the only reliable identifier there.
|
||||
*/
|
||||
export function resolveHardwareTerminalKey(input: {
|
||||
key?: string;
|
||||
code?: string;
|
||||
}): NativeTerminalKey | null {
|
||||
return resolveNativeTerminalKey(input.code ?? "") ?? resolveNativeTerminalKey(input.key ?? "");
|
||||
}
|
||||
|
||||
export function forwardNativeTerminalKey(input: {
|
||||
key: NativeTerminalKey;
|
||||
onTerminalKey?: NativeTerminalKeyHandler;
|
||||
}): void {
|
||||
input.onTerminalKey?.(createNativeTerminalKeyEvent(input.key));
|
||||
export function createNativeTerminalKeyEvent(
|
||||
key: NativeTerminalKey,
|
||||
modifiers: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean } = {},
|
||||
): NativeTerminalKeyEvent {
|
||||
return {
|
||||
key,
|
||||
ctrl: modifiers.ctrl ?? false,
|
||||
shift: modifiers.shift ?? false,
|
||||
alt: modifiers.alt ?? false,
|
||||
meta: modifiers.meta ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +115,26 @@ interface CreateTerminalHostInput {
|
||||
isMac?: boolean;
|
||||
}
|
||||
|
||||
/** Text typed into xterm's textarea, as the browser reports a typed character. */
|
||||
function typeText(mounted: MountedTerminal, text: string): void {
|
||||
mounted.host
|
||||
.querySelector(".xterm-helper-textarea")
|
||||
?.dispatchEvent(
|
||||
new InputEvent("input", { data: text, inputType: "insertText", bubbles: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/** A native paste of plain text into xterm's textarea. */
|
||||
function pasteText(mounted: MountedTerminal, text: string): void {
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.setData("text/plain", text);
|
||||
mounted.host
|
||||
.querySelector(".xterm-helper-textarea")
|
||||
?.dispatchEvent(
|
||||
new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
|
||||
const root = document.createElement("div");
|
||||
root.style.width = `${input.width}px`;
|
||||
@@ -824,6 +844,23 @@ describe("incremental replay lifecycle", () => {
|
||||
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps what the user types, but not pastes, while a replay is still parsing", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
mounted.runtime.replay({ type: "begin" });
|
||||
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07restored") });
|
||||
mounted.runtime.replay({ type: "end" });
|
||||
|
||||
typeText(mounted, "a");
|
||||
pasteText(mounted, "echo one\necho two\n");
|
||||
typeText(mounted, "f");
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }),
|
||||
);
|
||||
|
||||
expect(mounted.inputs).toEqual(["a", "f"]);
|
||||
});
|
||||
|
||||
it("measures emoji clusters with the daemon's grapheme widths and copies them intact", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
const text = "A😀B❤️C👋🏽D👨👩👧E";
|
||||
|
||||
@@ -124,6 +124,7 @@ interface TerminalEmulatorRuntimeDisposables {
|
||||
clearFitTimeouts: () => void;
|
||||
removeFontListeners: () => void;
|
||||
removeFilePasteListener: () => void;
|
||||
removeUserInputListeners: () => void;
|
||||
restoreDocumentStyles: () => void;
|
||||
restoreViewportStyles: () => void;
|
||||
disposeFitAddon: () => void;
|
||||
@@ -157,6 +158,10 @@ const isAppleHandheld =
|
||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
|
||||
// DOM events xterm turns into typed data on its textarea. Pastes stay suppressed during a
|
||||
// replay: until the snapshot restores bracketed paste mode, a multi-line paste would run
|
||||
// line by line in the shell.
|
||||
const USER_INPUT_EVENT_TYPES = ["keydown", "keypress", "input"] as const;
|
||||
const RESET_TERMINAL_OUTPUT = new Uint8Array([0x1b, 0x63]);
|
||||
const terminalOutputEncoder = new TextEncoder();
|
||||
|
||||
@@ -313,6 +318,8 @@ export class TerminalEmulatorRuntime {
|
||||
private hasUngatedWrites = false;
|
||||
private readonly inputModeDecoder = new TextDecoder();
|
||||
private suppressInput = false;
|
||||
// The latest user input event the host saw; its eventPhase is NONE once dispatch ends.
|
||||
private userInputEvent: Event | null = null;
|
||||
private readonly inputModeTracker = new TerminalInputModeTracker();
|
||||
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
|
||||
private themeBackgroundElements: HTMLElement[] = [];
|
||||
@@ -739,8 +746,17 @@ export class TerminalEmulatorRuntime {
|
||||
|
||||
fitAndEmitResize({ forceRefresh: true, shouldClaim: false });
|
||||
|
||||
// xterm reports what the user types and the replies it generates while parsing
|
||||
// through the same onData. Replies only come out of parsing a write, never while a
|
||||
// user input event is dispatching, so typing survives a replay that suppresses them.
|
||||
const recordUserInputEvent = (event: Event): void => {
|
||||
this.userInputEvent = event;
|
||||
};
|
||||
for (const type of USER_INPUT_EVENT_TYPES) {
|
||||
input.host.addEventListener(type, recordUserInputEvent, true);
|
||||
}
|
||||
const inputDisposable = terminal.onData((data) => {
|
||||
if (this.suppressInput) {
|
||||
if (this.suppressInput && !this.isDispatchingUserInput()) {
|
||||
return;
|
||||
}
|
||||
this.callbacks.onInput?.(data);
|
||||
@@ -844,6 +860,12 @@ export class TerminalEmulatorRuntime {
|
||||
removeFilePasteListener: () => {
|
||||
input.host.removeEventListener("paste", filePasteHandler, true);
|
||||
},
|
||||
removeUserInputListeners: () => {
|
||||
for (const type of USER_INPUT_EVENT_TYPES) {
|
||||
input.host.removeEventListener(type, recordUserInputEvent, true);
|
||||
}
|
||||
this.userInputEvent = null;
|
||||
},
|
||||
restoreDocumentStyles,
|
||||
restoreViewportStyles,
|
||||
disposeFitAddon: () => {
|
||||
@@ -873,6 +895,7 @@ export class TerminalEmulatorRuntime {
|
||||
disposables.clearFitTimeouts();
|
||||
disposables.removeFontListeners();
|
||||
disposables.removeFilePasteListener();
|
||||
disposables.removeUserInputListeners();
|
||||
disposables.disposeFitAddon();
|
||||
disposables.disposeWebglAddon();
|
||||
disposables.disposeTerminal();
|
||||
@@ -913,6 +936,10 @@ export class TerminalEmulatorRuntime {
|
||||
this.terminal?.paste(text);
|
||||
}
|
||||
|
||||
private isDispatchingUserInput(): boolean {
|
||||
return (this.userInputEvent?.eventPhase ?? Event.NONE) !== Event.NONE;
|
||||
}
|
||||
|
||||
private pasteClipboard(terminal: Pick<Terminal, "paste">): void {
|
||||
const text = this.clipboard.readText().catch(() => "");
|
||||
const onFilePaste = this.callbacks.onFilePaste;
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
deriveMacDockBadgeCountFromWorkspaceStatuses,
|
||||
createMacDockBadgeCountSelector,
|
||||
isWorkspaceActionableForDesktopBadge,
|
||||
selectMacDockBadgeCount,
|
||||
} from "./desktop-badge-state";
|
||||
|
||||
function workspace(id: string, status: WorkspaceDescriptor["status"]): WorkspaceDescriptor {
|
||||
return {
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: id,
|
||||
status,
|
||||
archivingAt: null,
|
||||
statusEnteredAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
function stateWith(...workspaces: WorkspaceDescriptor[]) {
|
||||
return {
|
||||
sessions: {
|
||||
srv_a: { workspaces: new Map(workspaces.map((entry) => [entry.id, entry])) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("desktop-badge-state", () => {
|
||||
it("treats attention-requiring workspace statuses as actionable", () => {
|
||||
expect(isWorkspaceActionableForDesktopBadge("attention")).toBe(true);
|
||||
@@ -17,18 +47,40 @@ describe("desktop-badge-state", () => {
|
||||
});
|
||||
|
||||
it("returns undefined when no visible workspaces need attention", () => {
|
||||
expect(deriveMacDockBadgeCountFromWorkspaceStatuses(["done", "running"])).toBeUndefined();
|
||||
expect(
|
||||
selectMacDockBadgeCount(stateWith(workspace("a", "done"), workspace("b", "running"))),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("counts only actionable visible workspaces", () => {
|
||||
expect(
|
||||
deriveMacDockBadgeCountFromWorkspaceStatuses([
|
||||
"done",
|
||||
"attention",
|
||||
"running",
|
||||
"needs_input",
|
||||
"failed",
|
||||
]),
|
||||
).toBe(3);
|
||||
it("counts only actionable visible workspaces across every host", () => {
|
||||
const state = {
|
||||
sessions: {
|
||||
srv_a: {
|
||||
workspaces: new Map([
|
||||
["a", workspace("a", "done")],
|
||||
["b", workspace("b", "attention")],
|
||||
]),
|
||||
},
|
||||
srv_b: {
|
||||
workspaces: new Map([
|
||||
["c", workspace("c", "running")],
|
||||
["d", workspace("d", "needs_input")],
|
||||
["e", workspace("e", "failed")],
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(selectMacDockBadgeCount(state)).toBe(3);
|
||||
});
|
||||
|
||||
it("recounts only when a workspace map is replaced", () => {
|
||||
const select = createMacDockBadgeCountSelector();
|
||||
const state = stateWith(workspace("a", "attention"));
|
||||
expect(select(state)).toBe(1);
|
||||
|
||||
state.sessions.srv_a.workspaces.set("b", workspace("b", "failed"));
|
||||
expect(select({ sessions: { ...state.sessions } })).toBe(1);
|
||||
|
||||
expect(select(stateWith(workspace("a", "attention"), workspace("b", "failed")))).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { memoizeOnSessionMaps } from "./memoize-on-session-maps";
|
||||
|
||||
export type DesktopBadgeWorkspaceStatus = WorkspaceDescriptor["status"];
|
||||
|
||||
interface WorkspaceStatusSession {
|
||||
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
|
||||
}
|
||||
|
||||
interface WorkspaceStatusSource {
|
||||
sessions: Record<string, WorkspaceStatusSession | undefined>;
|
||||
}
|
||||
|
||||
export function isWorkspaceActionableForDesktopBadge(status: DesktopBadgeWorkspaceStatus): boolean {
|
||||
return status === "attention" || status === "needs_input" || status === "failed";
|
||||
}
|
||||
|
||||
export function deriveMacDockBadgeCountFromWorkspaceStatuses(
|
||||
statuses: readonly DesktopBadgeWorkspaceStatus[],
|
||||
): number | undefined {
|
||||
const actionableCount = statuses.filter(isWorkspaceActionableForDesktopBadge).length;
|
||||
/**
|
||||
* The macOS dock badge only needs a count, so count here instead of materialising every
|
||||
* workspace status and comparing the whole list on every store write.
|
||||
*/
|
||||
export function selectMacDockBadgeCount(state: WorkspaceStatusSource): number | undefined {
|
||||
let actionableCount = 0;
|
||||
for (const session of Object.values(state.sessions)) {
|
||||
if (!session) continue;
|
||||
for (const workspace of session.workspaces.values()) {
|
||||
if (isWorkspaceActionableForDesktopBadge(workspace.status)) actionableCount += 1;
|
||||
}
|
||||
}
|
||||
return actionableCount > 0 ? actionableCount : undefined;
|
||||
}
|
||||
|
||||
/** A session-store selector for the dock badge count that recounts only when a workspace map changes. */
|
||||
export function createMacDockBadgeCountSelector(): (
|
||||
state: WorkspaceStatusSource,
|
||||
) => number | undefined {
|
||||
return memoizeOnSessionMaps<WorkspaceStatusSession, WorkspaceStatusSource, number | undefined>(
|
||||
(session) => session.workspaces,
|
||||
selectMacDockBadgeCount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { createFaviconStatusSelector, deriveFaviconStatus } from "./favicon-status";
|
||||
|
||||
function agent(input: Partial<Agent> & { id: string }): Agent {
|
||||
return {
|
||||
status: "idle",
|
||||
background: false,
|
||||
archivedAt: null,
|
||||
requiresAttention: false,
|
||||
pendingPermissions: [],
|
||||
...input,
|
||||
} as Agent;
|
||||
}
|
||||
|
||||
function stateWith(...agents: Agent[]) {
|
||||
return { sessions: { srv: { agents: new Map(agents.map((entry) => [entry.id, entry])) } } };
|
||||
}
|
||||
|
||||
describe("deriveFaviconStatus", () => {
|
||||
it("is running when any visible agent runs", () => {
|
||||
expect(
|
||||
deriveFaviconStatus(
|
||||
stateWith(
|
||||
agent({ id: "a", requiresAttention: true }),
|
||||
agent({ id: "b", status: "running" }),
|
||||
),
|
||||
),
|
||||
).toBe("running");
|
||||
});
|
||||
|
||||
it("needs attention for attention or pending permissions", () => {
|
||||
expect(deriveFaviconStatus(stateWith(agent({ id: "a", requiresAttention: true })))).toBe(
|
||||
"attention",
|
||||
);
|
||||
expect(
|
||||
deriveFaviconStatus(
|
||||
stateWith(agent({ id: "a", pendingPermissions: [{}] as Agent["pendingPermissions"] })),
|
||||
),
|
||||
).toBe("attention");
|
||||
});
|
||||
|
||||
it("ignores background and archived agents", () => {
|
||||
expect(
|
||||
deriveFaviconStatus(
|
||||
stateWith(
|
||||
agent({ id: "a", status: "running", background: true }),
|
||||
agent({ id: "b", requiresAttention: true, archivedAt: new Date() }),
|
||||
),
|
||||
),
|
||||
).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFaviconStatusSelector", () => {
|
||||
it("reuses its result while the agent maps are unchanged", () => {
|
||||
const select = createFaviconStatusSelector();
|
||||
const state = stateWith(agent({ id: "a", status: "running" }));
|
||||
expect(select(state)).toBe("running");
|
||||
|
||||
// Same agent map, mutated behind the store's back: the selector must not rescan.
|
||||
state.sessions.srv.agents.set("a", agent({ id: "a" }));
|
||||
expect(select({ sessions: { ...state.sessions } })).toBe("running");
|
||||
});
|
||||
|
||||
it("rescans when an agent map is replaced", () => {
|
||||
const select = createFaviconStatusSelector();
|
||||
expect(select(stateWith(agent({ id: "a", status: "running" })))).toBe("running");
|
||||
expect(select(stateWith(agent({ id: "a" })))).toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { memoizeOnSessionMaps } from "./memoize-on-session-maps";
|
||||
|
||||
export type FaviconStatus = "none" | "running" | "attention";
|
||||
|
||||
interface FaviconAgentSession {
|
||||
agents: ReadonlyMap<string, Agent>;
|
||||
}
|
||||
|
||||
interface FaviconAgentSource {
|
||||
sessions: Record<string, FaviconAgentSession | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The favicon is the only always-mounted consumer of the whole agent directory, so derive its
|
||||
* three states here instead of building the aggregated agent list: the list allocates a
|
||||
* projection per agent, deep-compares it and sorts every agent on the host on each update.
|
||||
*/
|
||||
export function deriveFaviconStatus(state: FaviconAgentSource): FaviconStatus {
|
||||
let hasAttention = false;
|
||||
let hasNeedsInput = false;
|
||||
for (const session of Object.values(state.sessions)) {
|
||||
if (!session) continue;
|
||||
for (const agent of session.agents.values()) {
|
||||
if (agent.background || agent.archivedAt) continue;
|
||||
if (agent.status === "running") return "running";
|
||||
if (agent.requiresAttention) hasAttention = true;
|
||||
if (agent.pendingPermissions.length > 0) hasNeedsInput = true;
|
||||
}
|
||||
}
|
||||
return hasAttention || hasNeedsInput ? "attention" : "none";
|
||||
}
|
||||
|
||||
/** A session-store selector for the favicon status that rescans only when an agent map changes. */
|
||||
export function createFaviconStatusSelector(): (state: FaviconAgentSource) => FaviconStatus {
|
||||
return memoizeOnSessionMaps<FaviconAgentSession, FaviconAgentSource, FaviconStatus>(
|
||||
(session) => session.agents,
|
||||
deriveFaviconStatus,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
interface SessionsSource<TSession> {
|
||||
sessions: Record<string, TSession | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a whole-store selector so it reruns only when one of the per-session maps it reads has
|
||||
* changed. Session-store writes that leave those maps alone (timeline updates, drafts) then cost
|
||||
* a reference check per host instead of a walk over every entry.
|
||||
*/
|
||||
export function memoizeOnSessionMaps<TSession, TState extends SessionsSource<TSession>, TResult>(
|
||||
pickMap: (session: TSession) => unknown,
|
||||
derive: (state: TState) => TResult,
|
||||
): (state: TState) => TResult {
|
||||
let previousMaps: unknown[] | null = null;
|
||||
let previousResult: TResult;
|
||||
return (state) => {
|
||||
const maps = Object.values(state.sessions).map((session) =>
|
||||
session ? pickMap(session) : undefined,
|
||||
);
|
||||
if (
|
||||
previousMaps &&
|
||||
previousMaps.length === maps.length &&
|
||||
previousMaps.every((map, index) => map === maps[index])
|
||||
) {
|
||||
return previousResult;
|
||||
}
|
||||
previousMaps = maps;
|
||||
previousResult = derive(state);
|
||||
return previousResult;
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -29,9 +29,9 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"description": "Paseo client SDK package",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
@@ -49,8 +49,8 @@
|
||||
"measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
@@ -78,8 +78,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",
|
||||
@@ -89,8 +89,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"
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
type ProviderSessionConfig,
|
||||
type ProviderSetting,
|
||||
type ProviderTimelineItem,
|
||||
type ProviderUsage,
|
||||
} from "../provider.js";
|
||||
|
||||
const ADAPTER_CAPABILITIES = [
|
||||
@@ -395,6 +396,7 @@ class AcpRuntime {
|
||||
private notificationLane: Promise<void> = Promise.resolve();
|
||||
private promptLane: Promise<void> = Promise.resolve();
|
||||
private activePrompt: { turnId: string; settled: Promise<void> } | null = null;
|
||||
private lastUsage: ProviderUsage = {};
|
||||
|
||||
static async start(options: StartRuntimeOptions): Promise<AcpRuntime> {
|
||||
let child: ChildProcessWithoutNullStreams | null = null;
|
||||
@@ -623,6 +625,20 @@ class AcpRuntime {
|
||||
).then(
|
||||
async (response): Promise<void> => {
|
||||
await this.drainNotifications();
|
||||
if (response.usage) {
|
||||
this.lastUsage = {
|
||||
...this.lastUsage,
|
||||
inputTokens: response.usage.inputTokens,
|
||||
outputTokens: response.usage.outputTokens,
|
||||
cachedInputTokens: response.usage.cachedReadTokens ?? undefined,
|
||||
};
|
||||
this.emit({
|
||||
type: "session.usage",
|
||||
sessionId: this.options.boundarySessionId,
|
||||
turnId,
|
||||
usage: this.lastUsage,
|
||||
});
|
||||
}
|
||||
const state = response.stopReason === "cancelled" ? "canceled" : "completed";
|
||||
this.fallbackChunkIds.clear();
|
||||
this.terminalizeTransientItems(state);
|
||||
@@ -1066,14 +1082,16 @@ class AcpRuntime {
|
||||
return;
|
||||
}
|
||||
if (update.sessionUpdate === "usage_update") {
|
||||
this.lastUsage = {
|
||||
...this.lastUsage,
|
||||
contextWindowUsedTokens: update.used,
|
||||
contextWindowMaxTokens: update.size,
|
||||
totalCostUsd: update.cost?.currency === "USD" ? update.cost.amount : undefined,
|
||||
};
|
||||
this.emit({
|
||||
type: "session.usage",
|
||||
sessionId: this.options.boundarySessionId,
|
||||
usage: {
|
||||
contextWindowUsedTokens: update.used,
|
||||
contextWindowMaxTokens: update.size,
|
||||
totalCostUsd: update.cost?.currency === "USD" ? update.cost.amount : undefined,
|
||||
},
|
||||
usage: this.lastUsage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1509,7 +1527,9 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem {
|
||||
name: snapshot.name ?? snapshot.title,
|
||||
detail,
|
||||
};
|
||||
if (status === "failed") return { ...base, status, error: snapshot.output };
|
||||
if (status === "failed") {
|
||||
return { ...base, status, error: snapshot.output ?? { message: "Tool failed" } };
|
||||
}
|
||||
return { ...base, status, error: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { ProviderConnection, ProviderEvent, ProviderInput } from "./provider.js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages";
|
||||
import { runAcpProvider, type AcpStreamMessage } from "./acp.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
@@ -792,6 +793,170 @@ lines.on("line", (line) => {
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ counts: null, context: false, expected: [] },
|
||||
{
|
||||
counts: { totalTokens: 0, inputTokens: 0, outputTokens: 0 },
|
||||
context: false,
|
||||
expected: [
|
||||
{
|
||||
turnId: "acp:first",
|
||||
usage: { inputTokens: 0, outputTokens: 0, cachedInputTokens: undefined },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
counts: { totalTokens: 18971, inputTokens: 12152, outputTokens: 78, cachedReadTokens: 6741 },
|
||||
context: true,
|
||||
expected: [
|
||||
{
|
||||
turnId: undefined,
|
||||
usage: { contextWindowUsedTokens: 42, contextWindowMaxTokens: 100, totalCostUsd: 0.25 },
|
||||
},
|
||||
{
|
||||
turnId: "acp:first",
|
||||
usage: {
|
||||
inputTokens: 12152,
|
||||
outputTokens: 78,
|
||||
cachedInputTokens: 6741,
|
||||
contextWindowUsedTokens: 42,
|
||||
contextWindowMaxTokens: 100,
|
||||
totalCostUsd: 0.25,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])(
|
||||
"forwards prompt-result usage $counts while preserving context updates: $context",
|
||||
async ({ counts, context, expected }) => {
|
||||
const harness = connectorHarness({
|
||||
handleRequest(instance, message) {
|
||||
if (message.method !== "session/prompt") return false;
|
||||
if (context)
|
||||
instance.notify("session/update", {
|
||||
sessionId: "connector-session",
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 42,
|
||||
size: 100,
|
||||
cost: { amount: 0.25, currency: "USD" },
|
||||
},
|
||||
});
|
||||
instance.respond(message, {
|
||||
stopReason: "end_turn",
|
||||
...(counts ? { usage: counts } : {}),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "usage-acp",
|
||||
label: "Usage ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
try {
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "first",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "hello" }] },
|
||||
},
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "session.turn" && event.state === "completed",
|
||||
);
|
||||
const usage = events.flatMap((event) =>
|
||||
event.type === "session.usage" ? [{ turnId: event.turnId, usage: event.usage }] : [],
|
||||
);
|
||||
expect(usage).toEqual(expected);
|
||||
const lastUsage = events.findLastIndex((event) => event.type === "session.usage");
|
||||
const completed = events.findIndex(
|
||||
(event) => event.type === "session.turn" && event.state === "completed",
|
||||
);
|
||||
expect(lastUsage).toBeLessThan(completed);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ status: "failed", output: undefined, error: { message: "Tool failed" } },
|
||||
{ status: "failed", output: null, error: { message: "Tool failed" } },
|
||||
{
|
||||
status: "failed",
|
||||
output: { message: "File not found" },
|
||||
error: { message: "File not found" },
|
||||
},
|
||||
{ status: "failed", output: "Permission denied", error: "Permission denied" },
|
||||
{ status: "completed", output: null, error: null },
|
||||
] as const)(
|
||||
"keeps a $status ACP tool with output $output valid on the history wire",
|
||||
async ({ status, output, error }) => {
|
||||
const harness = connectorHarness({
|
||||
promptUpdates: () => [
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "read-1",
|
||||
title: "Read file",
|
||||
kind: "read",
|
||||
status: "in_progress",
|
||||
},
|
||||
{ sessionUpdate: "tool_call_update", toolCallId: "read-1", status, rawOutput: output },
|
||||
],
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "tool-history-acp",
|
||||
label: "Tool history ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
try {
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "first",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "Read a file" }] },
|
||||
},
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "session.turn" && event.state === "completed",
|
||||
);
|
||||
const rows = events.flatMap((event) =>
|
||||
event.type === "timeline.item" && event.item.id === "read-1" ? [event.item] : [],
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
for (const row of rows) {
|
||||
expect(AgentTimelineItemPayloadSchema.safeParse(row).success).toBe(true);
|
||||
}
|
||||
expect(rows.at(-1)).toMatchObject({ type: "tool_call", status, error });
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("serializes configuration mutations", async () => {
|
||||
let releaseMutation: (() => void) | null = null;
|
||||
const configOptions = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"description": "Paseo backend server",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
@@ -109,11 +109,11 @@
|
||||
"@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",
|
||||
|
||||
@@ -1529,6 +1529,110 @@ test("does not replace a newer foreground turn after unavailable steer fallback
|
||||
}
|
||||
});
|
||||
|
||||
test("concurrent interrupting sends each start their own turn in order", async () => {
|
||||
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
||||
const { manager, agentId, workdir } = await startAndSteerThroughManager(session, "interrupt");
|
||||
try {
|
||||
const prompts = ["first interrupt", "second interrupt", "third interrupt"];
|
||||
await Promise.all(
|
||||
prompts.map((prompt) =>
|
||||
startAgentRun(manager, agentId, prompt, logger, {
|
||||
replaceRunning: true,
|
||||
activeTurnBehavior: "interrupt",
|
||||
runOptions: { clientMessageId: prompt },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await manager.waitForAgentRunStart(agentId);
|
||||
expect(session.startPrompts.slice(-3)).toEqual(prompts);
|
||||
expect(manager.getAgent(agentId)?.activeForegroundTurnId).toBe(
|
||||
`active-turn-${session.startCount}`,
|
||||
);
|
||||
} finally {
|
||||
await manager.closeAgent(agentId);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Stop can cancel startup while an interrupting send waits behind it", async () => {
|
||||
const entered = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
class SlowStartSession extends SteeringTestSession {
|
||||
starting = false;
|
||||
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
if (prompt === "slow startup") {
|
||||
this.starting = true;
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
this.starting = false;
|
||||
}
|
||||
return super.startTurn(prompt);
|
||||
}
|
||||
override async interrupt(): Promise<void> {
|
||||
if (this.starting) release.resolve();
|
||||
await super.interrupt();
|
||||
}
|
||||
}
|
||||
const session = new SlowStartSession({ provider: "codex", cwd: process.cwd() });
|
||||
const { manager, agentId, workdir } = await startAndSteerThroughManager(session, "interrupt");
|
||||
try {
|
||||
await startAgentRun(manager, agentId, "slow startup", logger, { replaceRunning: true });
|
||||
await entered.promise;
|
||||
const replacement = startAgentRun(manager, agentId, "queued replacement", logger, {
|
||||
replaceRunning: true,
|
||||
});
|
||||
void replacement.catch(() => undefined);
|
||||
const stop = manager.cancelAgentRun(agentId);
|
||||
await vi.waitFor(() => expect(session.interruptCount).toBe(3), { timeout: 1000 });
|
||||
await stop;
|
||||
await replacement;
|
||||
await manager.waitForAgentRunStart(agentId);
|
||||
expect(session.startPrompts.slice(-2)).toEqual(["slow startup", "queued replacement"]);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await manager.closeAgent(agentId);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("an interrupting send replaces a turn whose start never finishes", async () => {
|
||||
class StalledStartSession extends SteeringTestSession {
|
||||
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
if (prompt === "stalled start") {
|
||||
this.startPrompts.push(prompt);
|
||||
return new Promise(() => {});
|
||||
}
|
||||
return super.startTurn(prompt);
|
||||
}
|
||||
}
|
||||
const session = new StalledStartSession({ provider: "codex", cwd: process.cwd() });
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stalled-start-"));
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
logger,
|
||||
rescueTimeouts: { interruptSessionMs: 10 },
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
try {
|
||||
await startAgentRun(manager, agent.id, "stalled start", logger, { replaceRunning: true });
|
||||
await startAgentRun(manager, agent.id, "recovery", logger, {
|
||||
replaceRunning: true,
|
||||
activeTurnBehavior: "interrupt",
|
||||
});
|
||||
expect(session.startPrompts).toEqual(["stalled start", "recovery"]);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("steers a tracked autonomous turn without creating a replacement run", async () => {
|
||||
const session = new SteeringTestSession({ provider: "claude", cwd: process.cwd() });
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-autonomous-steer-"));
|
||||
|
||||
@@ -844,6 +844,7 @@ export class AgentManager {
|
||||
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
||||
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
|
||||
private readonly foregroundMutationTails = new Map<string, Promise<void>>();
|
||||
private readonly replacementMutationTails = new Map<string, Promise<void>>();
|
||||
private readonly runs = new AgentRunState();
|
||||
private readonly subscribers = new Set<SubscriptionRecord>();
|
||||
private readonly idFactory: () => string;
|
||||
@@ -2112,6 +2113,7 @@ export class AgentManager {
|
||||
agent.inFlightPermissionResponses.size === 0 &&
|
||||
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
|
||||
!this.foregroundMutationTails.has(agent.id) &&
|
||||
!this.replacementMutationTails.has(agent.id) &&
|
||||
!this.sessionEventTails.has(agent.id) &&
|
||||
!this.providerSubagents.list(agent.id).some((child) => child.status === "running")
|
||||
);
|
||||
@@ -3414,31 +3416,8 @@ export class AgentManager {
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
||||
const snapshot = this.requireAgent(agentId);
|
||||
if (
|
||||
snapshot.lifecycle !== "running" &&
|
||||
!snapshot.activeForegroundTurnId &&
|
||||
!this.runs.hasRun(agentId)
|
||||
) {
|
||||
return this.streamAgent(agentId, prompt, options);
|
||||
}
|
||||
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
agent.pendingReplacement = true;
|
||||
agent.lifecycle = "running";
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
|
||||
try {
|
||||
await this.cancelAgentRunBefore(agentId, "replace");
|
||||
return this.streamAgent(agentId, prompt, options);
|
||||
} catch (error) {
|
||||
const latest = this.agents.get(agentId);
|
||||
if (latest) {
|
||||
latest.pendingReplacement = false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.requireSessionAgent(agentId);
|
||||
return this.replaceRunInForegroundLane(agentId, prompt, options);
|
||||
}
|
||||
|
||||
async steerAgentRun(
|
||||
@@ -3562,18 +3541,26 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private async runForegroundMutation<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.foregroundMutationTails.get(agentId) ?? Promise.resolve();
|
||||
return this.runQueuedMutation(this.foregroundMutationTails, agentId, operation);
|
||||
}
|
||||
|
||||
private async runQueuedMutation<T>(
|
||||
tails: Map<string, Promise<void>>,
|
||||
agentId: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = tails.get(agentId) ?? Promise.resolve();
|
||||
const run = previous.catch(() => undefined).then(operation);
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.foregroundMutationTails.set(agentId, tail);
|
||||
tails.set(agentId, tail);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (this.foregroundMutationTails.get(agentId) === tail) {
|
||||
this.foregroundMutationTails.delete(agentId);
|
||||
if (tails.get(agentId) === tail) {
|
||||
tails.delete(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3585,20 +3572,81 @@ export class AgentManager {
|
||||
options?: AgentRunOptions,
|
||||
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
||||
this.assertSteerAdmissionOwnsTurn(agent, expectedTurnId);
|
||||
agent.pendingReplacement = true;
|
||||
agent.lifecycle = "running";
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
return this.replaceRunInForegroundLane(agent.id, prompt, options, (live) =>
|
||||
this.assertSteerAdmissionOwnsTurn(live, expectedTurnId),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.cancelAgentRunBefore(agent.id, "replace");
|
||||
return this.streamAgent(agent.id, prompt, options);
|
||||
} catch (error) {
|
||||
const latest = this.agents.get(agent.id);
|
||||
if (latest) {
|
||||
latest.pendingReplacement = false;
|
||||
/**
|
||||
* Cancels the current run and reserves its replacement in one foreground mutation, so a
|
||||
* concurrent replacement or Stop cannot land between the cancel and the new run's start.
|
||||
*/
|
||||
private async replaceRunInForegroundLane(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options: AgentRunOptions | undefined,
|
||||
assertAdmission?: (agent: ActiveManagedAgent) => void,
|
||||
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
||||
// Replacements queue in arrival order; each one holds the foreground lane only while
|
||||
// it cancels and reserves, so Stop is never stuck behind a replacement that is waiting.
|
||||
return this.runQueuedMutation(this.replacementMutationTails, agentId, () =>
|
||||
this.replaceRunWhenAdmitted(agentId, prompt, options, assertAdmission),
|
||||
);
|
||||
}
|
||||
|
||||
private async replaceRunWhenAdmitted(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options: AgentRunOptions | undefined,
|
||||
assertAdmission?: (agent: ActiveManagedAgent) => void,
|
||||
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
||||
let stalledStart: PendingForegroundRun | null = null;
|
||||
for (;;) {
|
||||
const admission = await this.runForegroundMutation(agentId, async () => {
|
||||
// Resolve the agent inside the lane: a reload while this send waited replaces it.
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
// A run that is still starting has no turn to interrupt yet. Canceling it would drop
|
||||
// its prompt, so wait for it outside the lane, where Stop can still reach it. A start
|
||||
// that outlasts the rescue timeout is canceled like any other run.
|
||||
const starting = this.runs.getPendingRun(agent.id);
|
||||
if (starting && starting.start.status !== "started" && starting !== stalledStart) {
|
||||
return { status: "starting" as const, run: starting };
|
||||
}
|
||||
assertAdmission?.(agent);
|
||||
if (!this.hasInFlightRun(agent.id)) {
|
||||
return {
|
||||
status: "reserved" as const,
|
||||
iterator: this.streamAgent(agent.id, prompt, options),
|
||||
};
|
||||
}
|
||||
agent.pendingReplacement = true;
|
||||
agent.lifecycle = "running";
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
try {
|
||||
const result = await this.cancelAgentRunNow(agent.id);
|
||||
if (result.status === "refused") {
|
||||
throw new AgentRunCancellationError(agent.id, "replace");
|
||||
}
|
||||
return {
|
||||
status: "reserved" as const,
|
||||
iterator: this.streamAgent(agent.id, prompt, options),
|
||||
};
|
||||
} catch (error) {
|
||||
agent.pendingReplacement = false;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
if (admission.status === "reserved") {
|
||||
return admission.iterator;
|
||||
}
|
||||
const started = await this.waitWithTimeout({
|
||||
operation: Promise.race([admission.run.turnStartedPromise, admission.run.settledPromise]),
|
||||
timeoutMs: this.rescueTimeouts.interruptSessionMs,
|
||||
});
|
||||
if (started === "timed_out") {
|
||||
stalledStart = admission.run;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
ImportProviderSessionContext,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "./provider-session-import.js";
|
||||
|
||||
const SESSION_FILE = "/Users/dev/.omp/agent/sessions/project/2026-01-01_session-id.jsonl";
|
||||
const SESSION_ID = "session-id";
|
||||
|
||||
function config(cwd: string): AgentSessionConfig {
|
||||
return { provider: "omp", cwd };
|
||||
}
|
||||
|
||||
function context(cwd: string): ImportProviderSessionContext {
|
||||
return { config: config(cwd), storedConfig: config(cwd) };
|
||||
}
|
||||
|
||||
function fakeSession(described: AgentPersistenceHandle | null): AgentSession {
|
||||
return {
|
||||
async *streamHistory() {},
|
||||
describePersistence: () => described,
|
||||
} as unknown as AgentSession;
|
||||
}
|
||||
|
||||
describe("importSessionFromPersistence", () => {
|
||||
it("persists the resumed session identity instead of the listed file handle", async () => {
|
||||
const cwd = "/Users/dev/code/project";
|
||||
const imported = await importSessionFromPersistence({
|
||||
provider: "omp",
|
||||
request: { providerHandleId: SESSION_FILE, cwd },
|
||||
context: context(cwd),
|
||||
resumeSession: async () =>
|
||||
fakeSession({
|
||||
provider: "omp",
|
||||
sessionId: SESSION_ID,
|
||||
nativeHandle: SESSION_FILE,
|
||||
metadata: { cwd, model: "resumed-model" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(imported.persistence).toMatchObject({
|
||||
provider: "omp",
|
||||
sessionId: SESSION_ID,
|
||||
nativeHandle: SESSION_FILE,
|
||||
metadata: { cwd, provider: "omp", model: "resumed-model" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the requested handle when the provider describes no identity", async () => {
|
||||
const cwd = "/Users/dev/code/project";
|
||||
const imported = await importSessionFromPersistence({
|
||||
provider: "omp",
|
||||
request: { providerHandleId: SESSION_FILE, cwd },
|
||||
context: context(cwd),
|
||||
resumeSession: async () => fakeSession(null),
|
||||
});
|
||||
|
||||
expect(imported.persistence).toMatchObject({
|
||||
provider: "omp",
|
||||
sessionId: SESSION_FILE,
|
||||
nativeHandle: SESSION_FILE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,12 +38,32 @@ export async function importSessionFromPersistence(input: {
|
||||
return {
|
||||
session,
|
||||
config: storedConfig,
|
||||
persistence,
|
||||
persistence: canonicalImportPersistence(persistence, session),
|
||||
timeline: history.timeline,
|
||||
providerSubagentEvents: history.providerSubagentEvents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import handles identify a session by whatever the provider lists, which is not
|
||||
* always the session id: OMP lists session files. Persist the resumed session's own
|
||||
* identity instead, so records, dedupe and resume all agree, while the provider's
|
||||
* native handle still resolves the session.
|
||||
*/
|
||||
function canonicalImportPersistence(
|
||||
requested: AgentPersistenceHandle,
|
||||
session: ImportedProviderSession["session"],
|
||||
): AgentPersistenceHandle {
|
||||
const described = session.describePersistence();
|
||||
if (!described?.sessionId) return requested;
|
||||
return {
|
||||
...described,
|
||||
provider: requested.provider,
|
||||
nativeHandle: described.nativeHandle ?? requested.nativeHandle,
|
||||
metadata: { ...requested.metadata, ...described.metadata },
|
||||
};
|
||||
}
|
||||
|
||||
function buildImportPersistenceHandle(
|
||||
provider: AgentProvider,
|
||||
input: ImportProviderSessionInput,
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("Codex thread history", () => {
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a page too big to send shows a warning row and stops loading", async () => {
|
||||
test("a page too big to send retries smaller pages, then warns at the item that cannot fit", async () => {
|
||||
const answers = Array.from({ length: 120 }, (_, index) =>
|
||||
agentItem(`agent-${index}`, `Answer ${index}`),
|
||||
);
|
||||
@@ -163,11 +163,12 @@ describe("Codex thread history", () => {
|
||||
);
|
||||
const loaded = [
|
||||
{ type: "user_message", text: "One", messageId: "user-1" },
|
||||
...answers.slice(0, 99).map((answer) => ({
|
||||
...answers.map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
{ type: "user_message", text: "Two", messageId: "user-2" },
|
||||
{
|
||||
type: "notification",
|
||||
level: "warning",
|
||||
@@ -176,6 +177,12 @@ describe("Codex thread history", () => {
|
||||
];
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual(loaded);
|
||||
// Each oversized page retries the same cursor at half the size until it fits.
|
||||
expect(
|
||||
historyRequests(appServer)
|
||||
.filter((request) => request.method === "thread/items/list")
|
||||
.map((request) => (request.params as { limit: number }).limit),
|
||||
).toEqual([100, 100, 50, 25, 12, 12, 6, 6, 3, 3, 1, 1]);
|
||||
// The oversized reply failed only its own request; the session still answers.
|
||||
expect(timelineItems(await readHistory(session))).toEqual(loaded);
|
||||
} finally {
|
||||
|
||||
@@ -124,15 +124,28 @@ async function* readPaginatedHistory(
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
const seenCursors = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
let limit = PAGE_SIZE;
|
||||
for (;;) {
|
||||
const page = CodexItemsPageSchema.parse(
|
||||
await requestHistory(
|
||||
client,
|
||||
"thread/items/list",
|
||||
{ threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor },
|
||||
let response: unknown;
|
||||
try {
|
||||
response = await client.request("thread/items/list", {
|
||||
threadId,
|
||||
limit,
|
||||
sortDirection: "asc",
|
||||
cursor,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof CodexAppServerResponseTooLargeError)) throw error;
|
||||
// Image-heavy items can push a full page past the line cap; retry the same cursor smaller.
|
||||
if (limit > 1) {
|
||||
limit = Math.max(1, Math.floor(limit / 2));
|
||||
continue;
|
||||
}
|
||||
throw new CodexThreadHistoryTooLargeError(
|
||||
"The rest of this Codex thread's history is too large to load.",
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
const page = CodexItemsPageSchema.parse(response);
|
||||
for (const row of page.data) {
|
||||
yield {
|
||||
item: row.item,
|
||||
|
||||
@@ -77,6 +77,157 @@ function readPluginOptions(env: Record<string, string>): {
|
||||
}
|
||||
|
||||
describe("OpenCodeBridge", () => {
|
||||
test("discovers only the requested session's catalog and retains global discovery", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-discovery-"));
|
||||
temporaryDirectories.push(paseoHome);
|
||||
const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() });
|
||||
const catalog = createCatalog();
|
||||
bridge.setManifestCatalog(catalog);
|
||||
await bridge.start();
|
||||
const unbind = bridge.bindSession({ sessionId: "bound", env: {}, tools: catalog });
|
||||
bridge.bindSession({ sessionId: "disabled", env: {} });
|
||||
try {
|
||||
const { baseUrl, token } = bridge.sharedV2Connection();
|
||||
async function discover(query: string) {
|
||||
const response = await fetch(`${baseUrl}/_internal/opencode/tools${query}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
const expected = {
|
||||
status: 200,
|
||||
body: {
|
||||
tools: [
|
||||
{
|
||||
name: "echo_context",
|
||||
title: "Echo context",
|
||||
description: "Returns the supplied value.",
|
||||
inputSchema: serializePaseoToolInputParameters(catalog.getTool("echo_context")!),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(await discover("")).toEqual(expected);
|
||||
expect(await discover("?sessionID=bound")).toEqual(expected);
|
||||
expect(await discover("?sessionID=disabled")).toEqual({ status: 200, body: { tools: [] } });
|
||||
const missing = {
|
||||
status: 404,
|
||||
body: { error: "OpenCode session is not bound to a Paseo agent" },
|
||||
};
|
||||
expect(await discover("?sessionID=unknown")).toEqual(missing);
|
||||
expect(await discover("?sessionID=")).toEqual(missing);
|
||||
unbind();
|
||||
expect(await discover("?sessionID=bound")).toEqual(missing);
|
||||
} finally {
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("registers and executes session-only tools without exposing them to other sessions", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-session-tools-"));
|
||||
temporaryDirectories.push(paseoHome);
|
||||
const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() });
|
||||
const builtin = createCatalog();
|
||||
const pluginTool = {
|
||||
name: "plugin_action",
|
||||
description: "Run a session-specific plugin action.",
|
||||
inputSchema: { value: z.string() },
|
||||
};
|
||||
const tools = new Map([...builtin.tools, [pluginTool.name, pluginTool] as const]);
|
||||
const calls: unknown[] = [];
|
||||
const author: PaseoToolCatalog = {
|
||||
tools,
|
||||
getTool: (name) => tools.get(name),
|
||||
executeTool: async (name, input, context) => {
|
||||
if (name !== pluginTool.name) return builtin.executeTool(name, input, context);
|
||||
calls.push(input);
|
||||
return { content: [{ type: "text", text: "Plugin action complete" }] };
|
||||
},
|
||||
};
|
||||
bridge.setManifestCatalog(builtin);
|
||||
await bridge.start();
|
||||
bridge.bindSession({ sessionId: "author/one", env: {}, tools: author });
|
||||
bridge.bindSession({ sessionId: "other", env: {}, tools: builtin });
|
||||
bridge.bindSession({ sessionId: "disabled", env: {} });
|
||||
try {
|
||||
const registered = new Map<string, V2TestTool>();
|
||||
const hooks: Array<(input: V2TestContext) => Promise<void>> = [];
|
||||
const dispose = await setupBoundV2Plugin(
|
||||
bridge,
|
||||
{
|
||||
tool: {
|
||||
transform: async (transform) => {
|
||||
transform({
|
||||
add: (tool) => {
|
||||
registered.set(tool.name, tool);
|
||||
},
|
||||
});
|
||||
return { dispose: async () => undefined };
|
||||
},
|
||||
},
|
||||
session: {
|
||||
context: async () => [],
|
||||
get: async ({ sessionID }) => ({
|
||||
parentID: sessionID === "child" ? "author/one" : undefined,
|
||||
}),
|
||||
hook: async (_name, hook) => {
|
||||
hooks.push(hook);
|
||||
return { dispose: async () => undefined };
|
||||
},
|
||||
},
|
||||
},
|
||||
["author/one", "other", "disabled"],
|
||||
);
|
||||
try {
|
||||
expect([...registered.keys()]).toEqual([
|
||||
"paseo_structured_output",
|
||||
"paseo_echo_context",
|
||||
`paseo_${pluginTool.name}`,
|
||||
]);
|
||||
const submit = registered.get(`paseo_${pluginTool.name}`)!;
|
||||
expect(submit.input).toEqual(serializePaseoToolInputParameters(pluginTool));
|
||||
const visibleTools = [
|
||||
["author/one", ["paseo_echo_context", `paseo_${pluginTool.name}`, "native"]],
|
||||
["child", ["paseo_echo_context", `paseo_${pluginTool.name}`, "native"]],
|
||||
["other", ["paseo_echo_context", "native"]],
|
||||
["disabled", ["native"]],
|
||||
["unrelated", ["native"]],
|
||||
] as const;
|
||||
for (const [sessionID, expected] of visibleTools) {
|
||||
const input = {
|
||||
sessionID,
|
||||
tools: Object.fromEntries([...registered.keys(), "native"].map((name) => [name, {}])),
|
||||
};
|
||||
for (const hook of hooks) await hook(input);
|
||||
expect(Object.keys(input.tools)).toEqual(expected);
|
||||
}
|
||||
await expect(
|
||||
submit.execute({ value: "revision" }, { sessionID: "author/one" }),
|
||||
).resolves.toMatchObject({ content: [{ type: "text", text: "Plugin action complete" }] });
|
||||
await expect(
|
||||
submit.execute({ value: "child revision" }, { sessionID: "child" }),
|
||||
).resolves.toMatchObject({ content: [{ type: "text", text: "Plugin action complete" }] });
|
||||
await expect(submit.execute({}, { sessionID: "other" })).rejects.toThrow(
|
||||
"Paseo tool not found",
|
||||
);
|
||||
await expect(submit.execute({}, { sessionID: "disabled" })).rejects.toThrow(
|
||||
"Paseo tools are disabled",
|
||||
);
|
||||
await expect(submit.execute({}, { sessionID: "unrelated" })).rejects.toThrow("not bound");
|
||||
expect(calls).toEqual([{ value: "revision" }, { value: "child revision" }]);
|
||||
await expect(
|
||||
registered
|
||||
.get("paseo_echo_context")!
|
||||
.execute({ value: "builtin" }, { sessionID: "other" }),
|
||||
).resolves.toMatchObject({ content: [{ type: "text", text: "builtin" }] });
|
||||
} finally {
|
||||
await dispose();
|
||||
}
|
||||
} finally {
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("advertises agent-scoped schemas that execute through both bridge plugins", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-manifest-"));
|
||||
temporaryDirectories.push(paseoHome);
|
||||
|
||||
@@ -166,7 +166,9 @@ export class OpenCodeBridge {
|
||||
}
|
||||
const url = new URL(request.url ?? "/", this.requireBaseUrl());
|
||||
if (request.method === "GET" && url.pathname === `${INTERNAL_PREFIX}/tools`) {
|
||||
sendJson(response, 200, { tools: this.serializeManifest() });
|
||||
sendJson(response, 200, {
|
||||
tools: this.serializeManifest(url.searchParams.get("sessionID")),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,8 +230,15 @@ export class OpenCodeBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private serializeManifest(): Array<Record<string, unknown>> {
|
||||
const catalog = this.manifestCatalog;
|
||||
private serializeManifest(sessionId: string | null): Array<Record<string, unknown>> {
|
||||
let catalog: Pick<PaseoToolCatalog, "tools"> | null | undefined = this.manifestCatalog;
|
||||
if (sessionId !== null) {
|
||||
const binding = this.sessions.get(sessionId);
|
||||
if (!binding) {
|
||||
throw new OpenCodeBridgeClientError(404, "OpenCode session is not bound to a Paseo agent");
|
||||
}
|
||||
catalog = binding.tools;
|
||||
}
|
||||
if (!catalog) return [];
|
||||
return [...catalog.tools.values()].map((tool) => {
|
||||
const definition: Record<string, unknown> = {
|
||||
|
||||
@@ -38,7 +38,10 @@ export async function registerSharedBridge(context) {
|
||||
return null;
|
||||
}
|
||||
async function install(binding) {
|
||||
const manifest = await request(binding, "/tools");
|
||||
const manifest = await request(
|
||||
binding,
|
||||
`/tools?sessionID=${encodeURIComponent(binding.sessionID)}`,
|
||||
);
|
||||
for (const definition of manifest.tools) await installDefinition(definition);
|
||||
}
|
||||
async function installDefinition(definition) {
|
||||
|
||||
@@ -27,7 +27,8 @@ test("a shared plugin binds Paseo sessions without involving unrelated sessions"
|
||||
const bridge = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() });
|
||||
const tool = { name: "echo", description: "echo", inputSchema: { value: z.string() } };
|
||||
const tools = new Map([[tool.name, tool]]);
|
||||
bridge.setManifestCatalog({ tools });
|
||||
// Session-only tools must survive plugin reload without a global definition.
|
||||
bridge.setManifestCatalog({ tools: new Map() });
|
||||
await bridge.start();
|
||||
const unbind = bridge.bindSession({
|
||||
sessionId: "paseo",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -13,6 +13,7 @@ import { WorkspaceScriptPayloadSchema } from "@getpaseo/protocol/messages";
|
||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||
import { readPaseoConfig } from "../utils/worktree.js";
|
||||
import { readPaseoConfigForEdit, writePaseoConfigForEdit } from "../utils/paseo-config-file.js";
|
||||
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
|
||||
@@ -471,6 +472,84 @@ describe("script-status-projection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every workspace's scripts when a listing's paseo.json checks do not finish in time", async () => {
|
||||
// More workspaces than the daemon kept snapshots for before, each with a config it has read.
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-many-")));
|
||||
const workspaces = Array.from({ length: 300 }, (_, index) => {
|
||||
const directory = path.join(tempDir, `workspace-${index}`);
|
||||
mkdirSync(directory);
|
||||
writeFileSync(
|
||||
path.join(directory, "paseo.json"),
|
||||
JSON.stringify({ scripts: { api: { type: "service", command: "npm run dev" } } }),
|
||||
);
|
||||
return directory;
|
||||
});
|
||||
const logger = createTestLogger();
|
||||
try {
|
||||
await Promise.all(
|
||||
workspaces.map((directory) => readPaseoConfigForProjection(directory, logger)),
|
||||
);
|
||||
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const listing = Promise.all(
|
||||
workspaces.map((directory) => readPaseoConfigForProjection(directory, logger)),
|
||||
);
|
||||
// The filesystem has not answered any check when the 5 s budget runs out.
|
||||
vi.advanceTimersByTime(5_000);
|
||||
const configs = await listing;
|
||||
|
||||
expect(configs.filter((config) => config?.scripts?.api === undefined)).toEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the saved scripts when the first check after an in-app save is late", async () => {
|
||||
const workspace = createWorkspaceRepo({
|
||||
paseoConfig: { scripts: { api: { type: "service", command: "npm run dev" } } },
|
||||
});
|
||||
const logger = createTestLogger();
|
||||
try {
|
||||
const current = await readPaseoConfigForEdit(workspace.repoDir);
|
||||
if (!current.ok) throw new Error("expected a readable paseo.json");
|
||||
const saved = await writePaseoConfigForEdit({
|
||||
repoRoot: workspace.repoDir,
|
||||
config: { scripts: { web: { type: "service", command: "npm run web" } } },
|
||||
expectedRevision: current.revision,
|
||||
});
|
||||
expect(saved.ok).toBe(true);
|
||||
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const listing = readPaseoConfigForProjection(workspace.repoDir, logger);
|
||||
vi.advanceTimersByTime(5_000);
|
||||
|
||||
expect(Object.keys((await listing)?.scripts ?? {})).toEqual(["web"]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows an external paseo.json edit on the next listing", async () => {
|
||||
const workspace = createWorkspaceRepo({
|
||||
paseoConfig: { scripts: { api: { type: "service", command: "npm run dev" } } },
|
||||
});
|
||||
try {
|
||||
await readPaseoConfigForProjection(workspace.repoDir, createTestLogger());
|
||||
writeFileSync(
|
||||
path.join(workspace.repoDir, "paseo.json"),
|
||||
JSON.stringify({ scripts: { web: { type: "service", command: "npm run web" } } }),
|
||||
);
|
||||
|
||||
const config = await readPaseoConfigForProjection(workspace.repoDir, createTestLogger());
|
||||
|
||||
expect(Object.keys(config?.scripts ?? {})).toEqual(["web"]);
|
||||
} finally {
|
||||
workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", async () => {
|
||||
const workspaceId = "workspace-null-config";
|
||||
const workspace = createWorkspaceRepo();
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
WorkspaceScriptPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js";
|
||||
import { readPaseoConfigOrLastKnown } from "../utils/paseo-config-file.js";
|
||||
import { getScriptConfigs, isServiceScript } from "../utils/worktree.js";
|
||||
import { deriveProjectSlug } from "./workspace-git-metadata.js";
|
||||
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
|
||||
import type {
|
||||
@@ -37,7 +38,7 @@ export async function readPaseoConfigForProjection(
|
||||
workspaceDirectory: string,
|
||||
logger: Logger,
|
||||
): Promise<PaseoConfig | null> {
|
||||
const result = await readPaseoConfig(workspaceDirectory);
|
||||
const result = await readPaseoConfigOrLastKnown(workspaceDirectory);
|
||||
if (result.ok) {
|
||||
return result.config;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,12 @@ interface Snapshot {
|
||||
runtime: ReadPaseoConfigResult;
|
||||
}
|
||||
|
||||
// How long a caller waits for the filesystem, including time queued behind other roots.
|
||||
const READ_BUDGET_MS = 5_000;
|
||||
// Above the roots a busy daemon lists: a listing that visits more roots than this evicts the next one it needs.
|
||||
const MAX_SNAPSHOTS = 1_024;
|
||||
const UNAVAILABLE = Symbol("project config unavailable");
|
||||
|
||||
/** Owns config admission, snapshots and daemon read/modify/write ordering for every consumer. */
|
||||
class ProjectConfigOwner {
|
||||
// A stalled mount retains its slot until the OS returns; never consume the whole fs pool.
|
||||
@@ -166,17 +172,39 @@ class ProjectConfigOwner {
|
||||
this.snapshots.delete(root);
|
||||
this.snapshots.set(root, snapshot);
|
||||
// Worktrees come and go; retain only a bounded set of reusable snapshots.
|
||||
if (this.snapshots.size > 256) this.snapshots.delete(this.snapshots.keys().next().value!);
|
||||
if (this.snapshots.size > MAX_SNAPSHOTS)
|
||||
this.snapshots.delete(this.snapshots.keys().next().value!);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async read(repoRoot: string): Promise<Snapshot> {
|
||||
const checked = await this.check(resolve(repoRoot));
|
||||
if (checked === UNAVAILABLE) throw new Error("Project config unavailable");
|
||||
return checked;
|
||||
}
|
||||
|
||||
/** A check the filesystem does not answer in time answers the last config read for that root. */
|
||||
async readOrLastKnown(repoRoot: string): Promise<Snapshot> {
|
||||
const root = resolve(repoRoot);
|
||||
return withTimeout(
|
||||
this.serialized(root, "read", () => this.load(root)),
|
||||
5_000,
|
||||
"Project config unavailable",
|
||||
);
|
||||
const checked = await this.check(root);
|
||||
if (checked !== UNAVAILABLE) return checked;
|
||||
const known = this.snapshots.get(root);
|
||||
if (!known) throw new Error("Project config unavailable");
|
||||
return known;
|
||||
}
|
||||
|
||||
private async check(root: string): Promise<Snapshot | typeof UNAVAILABLE> {
|
||||
let budget: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
this.serialized(root, "read", () => this.load(root)),
|
||||
new Promise<typeof UNAVAILABLE>((settle) => {
|
||||
budget = setTimeout(settle, READ_BUDGET_MS, UNAVAILABLE);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(budget);
|
||||
}
|
||||
}
|
||||
|
||||
private async load(root: string): Promise<Snapshot> {
|
||||
@@ -242,7 +270,7 @@ class ProjectConfigOwner {
|
||||
async write(input: WritePaseoConfigForEditInput): Promise<WritePaseoConfigForEditResult> {
|
||||
const root = resolve(input.repoRoot);
|
||||
return this.serialized(root, "write", async () => {
|
||||
const { edit: parsed } = await validateConfig(input.config);
|
||||
const { edit: parsed, runtime } = await validateConfig(input.config);
|
||||
if (!parsed.success) return { ok: false, error: { code: "invalid_project_config" } };
|
||||
const content = `${JSON.stringify(parsed.data, null, 2)}\n`;
|
||||
if (Buffer.byteLength(content) > MAX_PASEO_CONFIG_BYTES) {
|
||||
@@ -251,7 +279,8 @@ class ProjectConfigOwner {
|
||||
const tempPath = join(root, `.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`);
|
||||
try {
|
||||
await writeFile(tempPath, content, { flag: "wx" });
|
||||
const savedRevision = revision(await stat(tempPath));
|
||||
const savedStats = await stat(tempPath);
|
||||
const savedRevision = revision(savedStats);
|
||||
const currentStats = await inspect(root);
|
||||
if (currentStats) admit(currentStats);
|
||||
const currentRevision = currentStats ? revision(currentStats) : null;
|
||||
@@ -261,7 +290,13 @@ class ProjectConfigOwner {
|
||||
// Serialized against daemon edits. External writers still have the existing
|
||||
// optimistic stat/rename race; revision tokens remain mtime + size.
|
||||
await rename(tempPath, resolvePaseoConfigPath(root));
|
||||
this.snapshots.delete(root);
|
||||
// Remember what was saved without another read, so a late check right after the save
|
||||
// still has a config to show. A rename that changes ctime only costs the next check a reload.
|
||||
this.remember(root, {
|
||||
fingerprint: fingerprint(savedStats),
|
||||
edit: { ok: true, config: parsed.data, revision: savedRevision },
|
||||
runtime: { ok: true, config: runtime },
|
||||
});
|
||||
return { ok: true, config: parsed.data, revision: savedRevision };
|
||||
} catch {
|
||||
return { ok: false, error: { code: "write_failed" } };
|
||||
@@ -289,6 +324,18 @@ export async function readPaseoConfig(repoRoot: string): Promise<ReadPaseoConfig
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For callers that show a config, such as workspace listings and script status: a slow filesystem
|
||||
* answers the last config read instead of none. Edits and execution use readPaseoConfig.
|
||||
*/
|
||||
export async function readPaseoConfigOrLastKnown(repoRoot: string): Promise<ReadPaseoConfigResult> {
|
||||
try {
|
||||
return (await projectConfig.readOrLastKnown(repoRoot)).runtime;
|
||||
} catch (error) {
|
||||
return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error };
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPaseoConfigForEdit(
|
||||
repoRoot: string,
|
||||
): Promise<ReadPaseoConfigForEditResult> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user