Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] c65f02850b fix(app): preserve embedded Markdown containers during HTML normalization 2026-11-11 14:11:52 +00:00
paseo-bot[bot] 2648b4582e fix(app): preserve tag-leading code inside preformatted HTML 2026-11-11 14:01:40 +00:00
paseo-bot[bot]andgengjiawen c25052bc6c fix(app): load images from indented HTML tables without losing Markdown indentation
Keep the fix in HTML normalization: remove tag-leading layout indentation
while preserving Markdown list depth and preformatted text. The separate
image-row renderer is unnecessary for restoring image loading.

Co-authored-by: gengjiawen <technicalcute@gmail.com>
2026-11-11 13:55:58 +00:00
paseo-bot[bot] afda258425 Merge remote-tracking branch 'origin/main' into diagnose/9391-c51458dd 2026-11-11 13:51:43 +00:00
gengjiawen 94107c70c5 fix(app): lay out HTML image rows side by side in Markdown
A README screenshot table (an HTML block of <img width="170"> cells) rendered
as a column of full-width images: the Markdown engine has no per-image size
and treats every image on its own line as a full-width block. GitHub shows the
cells side by side at their declared width.

Top-level HTML blocks that hold only images (tables, centered badge rows,
<picture>) now render outside the engine as rows of images at their declared
width and height, wrapping when the pane is narrower than the row. Linked
images open their link; others open the lightbox. Local images load through
the same daemon path as Markdown images. Blocks with any text, headings or
other elements, and blocks nested in lists or quotes, stay with the engine.
2026-11-11 19:50:17 +08:00
gengjiawen a266c1a9ae fix(app): render images in indented HTML tables in Markdown
README screenshot rows are usually written as an HTML table with each
<td> indented on its own line. Converting the tags to Markdown kept that
indentation, so every image line started with four spaces and rendered
as an indented code block showing the raw `![alt](src)` text. Local
images inside it were never collected, so they never loaded.

HTML ignores indentation between tags. Inside HTML blocks, keep only the
quote or list prefix of each line and drop the rest, leaving code spans,
fences and <pre> content unchanged.
2026-11-11 19:04:52 +08:00
61 changed files with 520 additions and 1404 deletions
+1 -10
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.16.0-beta.2 - 2026-11-11
## 0.16.0-beta.1 - 2026-11-10
- **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,8 +30,6 @@
- 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
@@ -39,7 +37,6 @@
- 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))
@@ -51,7 +48,6 @@
- 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))
@@ -77,13 +73,11 @@
- 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))
@@ -115,13 +109,10 @@
### 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))
+1 -10
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.16.0-beta.2 - 2026-11-11
## 0.16.0-beta.1 - 2026-11-10
- **后台任务**: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,8 +30,6 @@
- 斜杠命令菜单现在显示加载和错误状态并提供“重试”,且不再默认选中 `/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))
### 修复
@@ -39,7 +37,6 @@
- 修复包含较短折叠轮次的聊天打开时留有空白、需滚动后才填满的问题 ([#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))
@@ -51,7 +48,6 @@
- 修复从侧边栏和“新建工作区”点击“添加主机”会跳转到“设置”的问题 ([#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))
@@ -77,13 +73,11 @@
- 修复 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))
@@ -115,13 +109,10 @@
### 插件
- 新增插件订阅,可将 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))
-9
View File
@@ -112,15 +112,6 @@ 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
-5
View File
@@ -85,11 +85,6 @@ 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
+1 -1
View File
@@ -160,7 +160,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. 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.
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.
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
View File
@@ -1 +1 @@
sha256-e6BNeOJvy1uyDe/XW9hdr7NBgJ+t9tlNum5oLNefREI=
sha256-vG7v6uq2ADtOILEuRq9q3wTDUENAhRBbzI7UGBsWOqo=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"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.2",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/server": "0.16.0-beta.2",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/server": "0.16.0-beta.1",
"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.2",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/relay": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"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.2",
"version": "0.16.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"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.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@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.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"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.2",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"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.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",
"@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",
"@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.2",
"version": "0.16.0-beta.1",
"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.2",
"version": "0.16.0-beta.1",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -6,7 +6,6 @@ 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) {
@@ -30,9 +29,7 @@ async function pressInComposer(page: Page, key: string) {
}
test.describe("Composer recall", () => {
test("recalls, edits, and sends messages while keeping slash autocomplete usable", async ({
page,
}) => {
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-",
title: "Composer recall",
@@ -40,44 +37,49 @@ test.describe("Composer recall", () => {
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
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 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");
});
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);
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();
}
});
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
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);
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
});
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
} finally {
await session.cleanup();
}
@@ -792,6 +792,16 @@ test.describe("CodeMirror workspace file editing", () => {
"",
"![hero](../data/cache/hero.png?v=2#preview)",
"",
"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",
);
@@ -805,6 +815,8 @@ 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"),
});
@@ -1,56 +0,0 @@
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);
});
});
@@ -8,12 +8,9 @@ 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'
}
@@ -1,6 +0,0 @@
<?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>
@@ -1,81 +0,0 @@
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)
}
}
}
@@ -10,24 +10,18 @@ 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
@@ -44,7 +38,7 @@ internal object HardwareKeyboard {
consumedPresses.clear()
}
fun dispatch(event: KeyEvent, composing: Boolean, terminalFocused: Boolean): Boolean {
fun dispatch(event: KeyEvent, composing: 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)
@@ -70,12 +64,8 @@ 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
// 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
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
return overlayOwnsKey
}
private fun code(keyCode: Int): String = when (keyCode) {
@@ -117,15 +107,9 @@ 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 focus = window.currentFocus
val editable = (focus as? EditText)?.text
val editable = (window.currentFocus as? EditText)?.text
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
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) },
)
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -63,25 +63,10 @@ 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,9 +40,6 @@ 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 },
});
}
@@ -100,15 +97,7 @@ function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSi
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
try {
initializeMermaid(message.colorScheme);
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,
);
const { svg } = await mermaid.render(`paseo-mermaid-${message.revision}`, message.source);
if (message.revision !== latestRevision) {
return;
}
File diff suppressed because one or more lines are too long
@@ -29,15 +29,9 @@ function waitForRuntimeMessage(
});
}
async function mountRuntime(
size?: { width: number; height: number },
options?: { allowSameOrigin?: boolean },
): Promise<HTMLIFrameElement> {
async function mountRuntime(size?: { width: number; height: number }): 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`;
@@ -118,103 +112,6 @@ 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")}`;
+76 -10
View File
@@ -9,6 +9,12 @@ 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",
@@ -30,33 +36,86 @@ 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;
const htmlRanges: { start: number; end: number }[] = [];
function findHtml(node: Nodes) {
// `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) {
if (node.type === "html" && node.position) {
htmlRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
} else if ("children" in node) node.children.forEach(findHtml);
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)));
}
findHtml(fromMarkdown(source));
findHtml(fromMarkdown(source), false);
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 protectCode(node: Nodes) {
function readMarkdownContext(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 });
} else if ("children" in node) node.children.forEach(protectCode);
}
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);
}
protectCode(tree);
readMarkdownContext(tree);
const links: (string | undefined)[] = [];
let previousTag: { name: string; end: number } | null = null;
const convert = (raw: string, offset: number): string => {
let preDepth = 0;
const convert = (raw: string, offset: number, indent: 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) {
@@ -77,6 +136,7 @@ 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
@@ -104,7 +164,13 @@ 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(HTML, (raw, offset) => convert(raw, range.start + offset));
.replace(range.indent === undefined ? HTML : HTML_OR_LINE_INDENT, (raw, offset) =>
convert(
raw,
range.start + offset,
containerIndentAt(range.start + offset, range.indent ?? 0, markdownContainers),
),
);
cursor = end;
}
return result + source.slice(cursor);
@@ -779,6 +779,99 @@ 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\* &lt;img&gt;</code>`;
expect(prepareMarkdownSource(source).render()).toBe("`" + String.raw`\*literal\* <img>` + "`");
@@ -1096,3 +1189,44 @@ 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);
});
@@ -51,7 +51,10 @@ import {
TERMINAL_SELECTION_GRIP_SIZE,
} from "../terminal/native-renderer/terminal-selection-controller";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
import { type NativeTerminalKeyEvent } from "../terminal/native-renderer/terminal-key-events";
import {
forwardNativeTerminalKey,
type NativeTerminalKey,
} from "../terminal/native-renderer/terminal-key-events";
import {
TerminalInput,
type TerminalInputHandle,
@@ -508,9 +511,9 @@ function NativeTerminalEmulator({
);
const handleNativeTerminalKey = useCallback(
(event: NativeTerminalKeyEvent) => {
(key: NativeTerminalKey) => {
claimActiveTerminalSizeForAction("key");
callbacksRef.current.onTerminalKey?.(event);
forwardNativeTerminalKey({ key, onTerminalKey: callbacksRef.current.onTerminalKey });
},
[claimActiveTerminalSizeForAction],
);
@@ -1,11 +1,10 @@
import { useMemo, useCallback, useRef, useSyncExternalStore } from "react";
import { useMemo, useCallback, useEffect, 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;
@@ -20,9 +19,6 @@ 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;
@@ -32,21 +28,19 @@ export function useAggregatedAgents(options?: {
const includeArchived = options?.includeArchived ?? false;
const demand = options?.demand ?? true;
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
useAllDirectoryDemand({ serverIds, enabled: demand });
useEffect(() => {
if (!demand) return;
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
return () => releases.forEach((release) => release());
}, [demand, runtime, serverIds]);
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;
@@ -70,16 +64,6 @@ 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),
@@ -174,7 +158,7 @@ export function useAggregatedAgents(options?: {
isInitialLoad,
isRevalidating,
};
}, [daemons, demand, includeArchived, runtime, runtimeVersion, sessionAgents]);
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
return {
...result,
@@ -14,21 +14,3 @@ 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]);
}
+32 -23
View File
@@ -1,13 +1,12 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAllDirectoryDemand } from "./use-directory-demand";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { getDesktopHost } from "@/desktop/host";
import { createMacDockBadgeCountSelector } from "@/utils/desktop-badge-state";
import { createFaviconStatusSelector, type FaviconStatus } from "@/utils/favicon-status";
import { useWorkspaceStatusesForBadges } from "@/stores/session-store-hooks";
import { deriveMacDockBadgeCountFromWorkspaceStatuses } from "@/utils/desktop-badge-state";
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 */
@@ -25,6 +24,21 @@ 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) {
@@ -80,15 +94,8 @@ async function updateMacDockBadge(count?: number) {
}
export function useFaviconStatus() {
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 { agents } = useAggregatedAgents({ demand: !isNative });
const workspaceStatuses = useWorkspaceStatusesForBadges();
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
@@ -105,15 +112,17 @@ export function useFaviconStatus() {
return () => mediaQuery.removeEventListener("change", handler);
}, []);
// Update favicon when agents or color scheme changes
useEffect(() => {
if (isNative) return;
updateFavicon(status, colorScheme);
}, [status, colorScheme]);
useEffect(() => {
if (isNative) return;
if (dockBadgeCount === lastDockBadgeCountRef.current) return;
lastDockBadgeCountRef.current = dockBadgeCount;
void updateMacDockBadge(dockBadgeCount);
}, [dockBadgeCount]);
const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme);
const dockBadgeCount = deriveMacDockBadgeCountFromWorkspaceStatuses(workspaceStatuses);
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
lastDockBadgeCountRef.current = dockBadgeCount;
void updateMacDockBadge(dockBadgeCount);
}
}, [agents, colorScheme, workspaceStatuses]);
}
-2
View File
@@ -8,8 +8,6 @@ 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";
@@ -17,16 +17,22 @@ 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 { WorkspaceStructure, WorkspaceStructureProject } from "./selectors";
export type {
DesktopBadgeWorkspaceStatus,
WorkspaceStructure,
WorkspaceStructureProject,
} from "./selectors";
export function useWorkspace(
serverId: string | null,
@@ -157,3 +163,11 @@ export function useHasWorkspaces(serverId: string | null): boolean {
workspaceEqualityFns.identity,
);
}
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceStatusesForBadges(state),
workspaceEqualityFns.deep,
);
}
@@ -14,6 +14,7 @@ import {
selectWorkspaceDirectory,
selectWorkspaceFields,
selectWorkspaceKeys,
selectWorkspaceStatusesForBadges,
selectWorkspaceStructureProjects,
workspaceEqualityFns,
type SidebarOrderSnapshot,
@@ -588,6 +589,36 @@ 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,9 +5,11 @@ 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 {
@@ -319,3 +321,15 @@ 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;
}
@@ -1,8 +1,7 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { shallow } from "zustand/shallow";
import { getAgentTabsNeedingOpenLabel, selectOpenTabAgents } from "./open-tab-labels";
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
const label = "paseo.open-agent-tab.client-1";
@@ -52,54 +51,3 @@ 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,24 +23,3 @@ 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,11 +1,10 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
import { useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/shallow";
import { useEffect, useRef, useState } from "react";
import { useSessionStore } from "@/stores/session-store";
import { getOrCreateClientId } from "@/utils/client-id";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { getAgentTabsNeedingOpenLabel, selectOpenTabAgents } from "./open-tab-labels";
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
function increment(value: number): number {
return value + 1;
@@ -17,16 +16,9 @@ export function useOpenAgentTabLabels(input: {
tabs: WorkspaceTab[];
enabled: boolean;
}): void {
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 agents = useSessionStore((state) => state.sessions[input.serverId]?.agents ?? null);
const agentDetails = useSessionStore(
(state) => state.sessions[input.serverId]?.agentDetails ?? null,
);
const pendingAgentIdsRef = useRef(new Set<string>());
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -47,13 +39,15 @@ export function useOpenAgentTabLabels(input: {
return;
}
const agentsById = new Map(openAgentIds.map((agentId, index) => [agentId, openAgents[index]]));
const openAgentIds = new Set(
input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
);
for (const agentId of pendingAgentIdsRef.current) {
if (!agentsById.has(agentId)) {
if (!openAgentIds.has(agentId)) {
pendingAgentIdsRef.current.delete(agentId);
}
}
if (agentsById.size === 0) {
if (openAgentIds.size === 0) {
return;
}
@@ -63,7 +57,7 @@ export function useOpenAgentTabLabels(input: {
const label = getOpenAgentTabLabel(clientId);
const agentIds = getAgentTabsNeedingOpenLabel({
tabs: input.tabs,
getAgent: (agentId) => agentsById.get(agentId),
getAgent: (agentId) => agents?.get(agentId) ?? agentDetails?.get(agentId),
label,
pendingAgentIds: pendingAgentIdsRef.current,
});
@@ -88,5 +82,5 @@ export function useOpenAgentTabLabels(input: {
console.warn("[OpenAgentTabLabels] Failed to resolve client ID", { error });
}
})();
}, [input.client, input.enabled, input.tabs, openAgentIds, openAgents, retryVersion]);
}, [agentDetails, agents, input.client, input.enabled, input.tabs, retryVersion]);
}
@@ -6,7 +6,7 @@ import {
TERMINAL_INPUT_CONTEXT_MENU_HIDDEN,
TERMINAL_INPUT_HITBOX_SIZE,
} from "./terminal-input.native";
import { createNativeTerminalKeyEvent, resolveHardwareTerminalKey } from "./terminal-key-events";
import { forwardNativeTerminalKey, type NativeTerminalKeyEvent } from "./terminal-key-events";
describe("native terminal typed input", () => {
function inputData(change: { data: string; shouldClear: boolean }): string {
@@ -149,18 +149,25 @@ describe("native terminal typed input", () => {
});
});
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,
it("forwards native arrow key events to the terminal key path", () => {
const events: NativeTerminalKeyEvent[] = [];
forwardNativeTerminalKey({
key: "ArrowUp",
onTerminalKey: (event) => {
events.push(event);
},
});
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,6 +1,5 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import {
Platform,
StyleSheet,
type NativeSyntheticEvent,
type StyleProp,
@@ -11,19 +10,10 @@ import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { hasActiveOverlay, subscribeHardwareKeyboard } from "@/keyboard";
import {
createNativeTerminalKeyEvent,
resolveHardwareTerminalKey,
resolveNativeTerminalKey,
type NativeTerminalKey,
type NativeTerminalKeyEvent,
} from "./terminal-key-events";
import { resolveNativeTerminalKey, type NativeTerminalKey } 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;
@@ -49,7 +39,7 @@ interface TerminalInputProps {
isKeyboardVisible: boolean;
onFocus?: () => void;
onInput?: (data: string) => void;
onTerminalKey?: (event: NativeTerminalKeyEvent) => void;
onTerminalKey?: (key: NativeTerminalKey) => void;
style?: StyleProp<TextStyle>;
}
@@ -197,8 +187,6 @@ 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]);
@@ -262,27 +250,6 @@ 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,
() => ({
@@ -326,7 +293,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
const change = inputState.receiveKeyPress(event.nativeEvent.key);
if (change.key) {
onTerminalKey?.(createNativeTerminalKeyEvent(change.key));
onTerminalKey?.(change.key);
}
if (change.data.length > 0) {
onInput?.(change.data);
@@ -354,7 +321,6 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
blurOnSubmit={false}
importantForAutofill="no"
multiline={true}
nativeID={TERMINAL_INPUT_NATIVE_ID}
onChangeText={handleChangeText}
onBlur={handleBlur}
onFocus={handleFocus}
@@ -22,26 +22,19 @@ export function resolveNativeTerminalKey(key: string): NativeTerminalKey | null
}
}
/**
* 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 createNativeTerminalKeyEvent(
key: NativeTerminalKey,
modifiers: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean } = {},
): NativeTerminalKeyEvent {
export function createNativeTerminalKeyEvent(key: NativeTerminalKey): NativeTerminalKeyEvent {
return {
key,
ctrl: modifiers.ctrl ?? false,
shift: modifiers.shift ?? false,
alt: modifiers.alt ?? false,
meta: modifiers.meta ?? false,
ctrl: false,
shift: false,
alt: false,
meta: false,
};
}
export function forwardNativeTerminalKey(input: {
key: NativeTerminalKey;
onTerminalKey?: NativeTerminalKeyHandler;
}): void {
input.onTerminalKey?.(createNativeTerminalKeyEvent(input.key));
}
@@ -1,39 +1,9 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
createMacDockBadgeCountSelector,
deriveMacDockBadgeCountFromWorkspaceStatuses,
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);
@@ -47,40 +17,18 @@ describe("desktop-badge-state", () => {
});
it("returns undefined when no visible workspaces need attention", () => {
expect(deriveMacDockBadgeCountFromWorkspaceStatuses(["done", "running"])).toBeUndefined();
});
it("counts only actionable visible workspaces", () => {
expect(
selectMacDockBadgeCount(stateWith(workspace("a", "done"), workspace("b", "running"))),
).toBeUndefined();
});
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);
deriveMacDockBadgeCountFromWorkspaceStatuses([
"done",
"attention",
"running",
"needs_input",
"failed",
]),
).toBe(3);
});
});
+4 -31
View File
@@ -1,41 +1,14 @@
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";
}
/**
* 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;
}
}
export function deriveMacDockBadgeCountFromWorkspaceStatuses(
statuses: readonly DesktopBadgeWorkspaceStatus[],
): number | undefined {
const actionableCount = statuses.filter(isWorkspaceActionableForDesktopBadge).length;
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,
);
}
@@ -1,71 +0,0 @@
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");
});
});
-40
View File
@@ -1,40 +0,0 @@
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,
);
}
@@ -1,31 +0,0 @@
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;
};
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"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.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/server": "0.16.0-beta.2",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/server": "0.16.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"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.2",
"@getpaseo/relay": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"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.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@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.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -47,7 +47,6 @@ import {
type ProviderSessionConfig,
type ProviderSetting,
type ProviderTimelineItem,
type ProviderUsage,
} from "../provider.js";
const ADAPTER_CAPABILITIES = [
@@ -396,7 +395,6 @@ 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;
@@ -625,20 +623,6 @@ 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);
@@ -1082,16 +1066,14 @@ 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: this.lastUsage,
usage: {
contextWindowUsedTokens: update.used,
contextWindowMaxTokens: update.size,
totalCostUsd: update.cost?.currency === "USD" ? update.cost.amount : undefined,
},
});
return;
}
@@ -1527,9 +1509,7 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem {
name: snapshot.name ?? snapshot.title,
detail,
};
if (status === "failed") {
return { ...base, status, error: snapshot.output ?? { message: "Tool failed" } };
}
if (status === "failed") return { ...base, status, error: snapshot.output };
return { ...base, status, error: null };
}
-165
View File
@@ -9,7 +9,6 @@ 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[] = [];
@@ -793,170 +792,6 @@ 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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"description": "Paseo shared protocol schemas and wire types",
"license": "Apache-2.0",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"description": "Paseo relay for bridging daemon and client connections",
"license": "Apache-2.0",
"files": [
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"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.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",
"@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",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -77,157 +77,6 @@ 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,9 +166,7 @@ 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(url.searchParams.get("sessionID")),
});
sendJson(response, 200, { tools: this.serializeManifest() });
return;
}
@@ -230,15 +228,8 @@ export class OpenCodeBridge {
}
}
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;
}
private serializeManifest(): Array<Record<string, unknown>> {
const catalog = this.manifestCatalog;
if (!catalog) return [];
return [...catalog.tools.values()].map((tool) => {
const definition: Record<string, unknown> = {
@@ -38,10 +38,7 @@ export async function registerSharedBridge(context) {
return null;
}
async function install(binding) {
const manifest = await request(
binding,
`/tools?sessionID=${encodeURIComponent(binding.sessionID)}`,
);
const manifest = await request(binding, "/tools");
for (const definition of manifest.tools) await installDefinition(definition);
}
async function installDefinition(definition) {
@@ -27,8 +27,7 @@ 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]]);
// Session-only tools must survive plugin reload without a global definition.
bridge.setManifestCatalog({ tools: new Map() });
bridge.setManifestCatalog({ tools });
await bridge.start();
const unbind = bridge.bindSession({
sessionId: "paseo",
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { tmpdir } from "node:os";
@@ -13,7 +13,6 @@ 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";
@@ -472,84 +471,6 @@ 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,8 +5,7 @@ import type {
WorkspaceScriptPayload,
} from "@getpaseo/protocol/messages";
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import { readPaseoConfigOrLastKnown } from "../utils/paseo-config-file.js";
import { getScriptConfigs, isServiceScript } from "../utils/worktree.js";
import { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js";
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
import type {
@@ -38,7 +37,7 @@ export async function readPaseoConfigForProjection(
workspaceDirectory: string,
logger: Logger,
): Promise<PaseoConfig | null> {
const result = await readPaseoConfigOrLastKnown(workspaceDirectory);
const result = await readPaseoConfig(workspaceDirectory);
if (result.ok) {
return result.config;
}
+9 -56
View File
@@ -114,12 +114,6 @@ 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.
@@ -172,39 +166,17 @@ 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 > MAX_SNAPSHOTS)
this.snapshots.delete(this.snapshots.keys().next().value!);
if (this.snapshots.size > 256) 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);
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);
}
return withTimeout(
this.serialized(root, "read", () => this.load(root)),
5_000,
"Project config unavailable",
);
}
private async load(root: string): Promise<Snapshot> {
@@ -270,7 +242,7 @@ class ProjectConfigOwner {
async write(input: WritePaseoConfigForEditInput): Promise<WritePaseoConfigForEditResult> {
const root = resolve(input.repoRoot);
return this.serialized(root, "write", async () => {
const { edit: parsed, runtime } = await validateConfig(input.config);
const { edit: parsed } = 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) {
@@ -279,8 +251,7 @@ class ProjectConfigOwner {
const tempPath = join(root, `.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`);
try {
await writeFile(tempPath, content, { flag: "wx" });
const savedStats = await stat(tempPath);
const savedRevision = revision(savedStats);
const savedRevision = revision(await stat(tempPath));
const currentStats = await inspect(root);
if (currentStats) admit(currentStats);
const currentRevision = currentStats ? revision(currentStats) : null;
@@ -290,13 +261,7 @@ 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));
// 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 },
});
this.snapshots.delete(root);
return { ok: true, config: parsed.data, revision: savedRevision };
} catch {
return { ok: false, error: { code: "write_failed" } };
@@ -324,18 +289,6 @@ 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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.16.0-beta.2",
"version": "0.16.0-beta.1",
"private": true,
"type": "module",
"scripts": {