mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b12852006 | ||
|
|
7c3a53a8b5 | ||
|
|
de50243885 | ||
|
|
5b7b8d6e81 | ||
|
|
25721c7847 | ||
|
|
78f580f9e8 | ||
|
|
8419c21227 | ||
|
|
5f014498c7 | ||
|
|
4495bda146 | ||
|
|
484c5ba224 | ||
|
|
1b8dbc38ab | ||
|
|
694696e82c | ||
|
|
a8032a1502 | ||
|
|
dc07efe937 | ||
|
|
36892d5be7 |
@@ -1,5 +1,65 @@
|
||||
# Changelog
|
||||
|
||||
## 0.14.0-beta.1 - 2026-10-27
|
||||
|
||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||
|
||||
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
|
||||
|
||||
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
|
||||
|
||||
### Added
|
||||
|
||||
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||
- Added sent-message recall with Up and Down in an empty web or desktop Composer ([#7710](https://github.com/getpaseo/paseo/pull/7710))
|
||||
- Added long-press Send to queue a message or send it immediately ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
|
||||
- Added project-specific editor choices, including Xcode on macOS ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
|
||||
|
||||
### Improved
|
||||
|
||||
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||
- Stopped hidden chats from rendering incoming text updates ([#7901](https://github.com/getpaseo/paseo/pull/7901))
|
||||
- Reduced stalls when highlighting and wrapping large diffs ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
|
||||
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
||||
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
||||
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
||||
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed terminal glyph corruption when shared WebGL texture atlases change ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
|
||||
- Fixed restored workspaces with no open chat staying on Loading workspace at launch on phones ([#7918](https://github.com/getpaseo/paseo/pull/7918))
|
||||
- Fixed restored agent tabs appearing in unrelated workspaces ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
|
||||
- Fixed background Git fetches opening credential prompts ([#8019](https://github.com/getpaseo/paseo/pull/8019))
|
||||
- Fixed failed SSH tunnels crashing callers with an unhandled EPIPE ([#7876](https://github.com/getpaseo/paseo/pull/7876))
|
||||
- Fixed brief Windows file locks preventing atomic saves ([#7955](https://github.com/getpaseo/paseo/pull/7955))
|
||||
- Fixed large ACP terminal output blocking daemon requests ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
|
||||
- Fixed messages sent during Pi manual compaction failing instead of waiting ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
|
||||
- Fixed late tool updates creating duplicate rows during overlapping turns ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
|
||||
- Fixed plugin ACP reasoning appearing inside assistant replies when message IDs match ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
|
||||
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||
|
||||
### Notes
|
||||
|
||||
- Voice mode now defaults to off; enable it in Voice settings
|
||||
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
|
||||
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
|
||||
|
||||
## 0.13.2 - 2026-10-26
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.14.0-beta.1 - 2026-10-27
|
||||
|
||||
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||
|
||||
已完成的对话轮次可以折叠为摘要,也可以展开查看完整对话。
|
||||
|
||||
语音设置现在支持配置语音服务商并一键下载本地模型,包括可用于中文听写的 SenseVoice 和 Whisper。语音模式现在需要主动开启。
|
||||
|
||||
### 新增
|
||||
|
||||
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||
- 新增在网页和桌面端的空 Composer 中按上下键回顾已发送消息 ([#7710](https://github.com/getpaseo/paseo/pull/7710))
|
||||
- 新增长按发送按钮排队发送或立即发送消息 ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
|
||||
- 新增按项目记忆编辑器的功能,macOS 支持 Xcode ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
|
||||
|
||||
### 改进
|
||||
|
||||
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||
- 隐藏的聊天不再渲染收到的文本更新 ([#7901](https://github.com/getpaseo/paseo/pull/7901))
|
||||
- 减少大型 diff 高亮和自动换行时的卡顿 ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
|
||||
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
|
||||
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
|
||||
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
|
||||
- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复共享 WebGL 纹理图集变化时终端字符显示错误的问题 ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
|
||||
- 修复手机启动时没有打开聊天的已恢复工作区停留在加载状态的问题 ([#7918](https://github.com/getpaseo/paseo/pull/7918))
|
||||
- 修复已恢复的 Agent 标签出现在无关工作区中的问题 ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
|
||||
- 修复后台 Git 拉取弹出凭据提示的问题 ([#8019](https://github.com/getpaseo/paseo/pull/8019))
|
||||
- 修复 SSH 隧道失败时未处理的 EPIPE 导致调用方崩溃的问题 ([#7876](https://github.com/getpaseo/paseo/pull/7876))
|
||||
- 修复 Windows 短暂文件锁导致原子保存失败的问题 ([#7955](https://github.com/getpaseo/paseo/pull/7955))
|
||||
- 修复 ACP 终端产生大量输出时阻塞 daemon 的问题 ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
|
||||
- 修复 Pi 手动压缩上下文期间发送消息直接失败而不等待的问题 ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
|
||||
- 修复轮次重叠时较晚的工具更新产生重复行的问题 ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
|
||||
- 修复插件 ACP 推理和回复的消息 ID 相同时,推理内容混入回复的问题 ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
|
||||
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||
|
||||
### 说明
|
||||
|
||||
- 语音模式默认关闭,可在语音设置中启用
|
||||
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
|
||||
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
|
||||
|
||||
## 0.13.2 - 2026-10-26
|
||||
|
||||
### 修复
|
||||
|
||||
+6
-4
@@ -150,18 +150,20 @@ One row off the rail makes the whole card look unconsidered.
|
||||
|
||||
Compact-first. The small case is designed; the large case adds chrome around it.
|
||||
|
||||
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
|
||||
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
|
||||
|
||||
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
|
||||
|
||||
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
|
||||
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
|
||||
- On wide layouts: a sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
|
||||
|
||||
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
|
||||
|
||||
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
|
||||
|
||||
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
|
||||
On a narrow window using the wide layout, app navigation yields when the remaining width cannot accommodate the content topology. Settings prefers a 720px split (320px list + 400px detail); its list shrinks first to keep forms readable without reducing the detail below 400px. Explorer reserves at least 240px plus a 360px workspace. These are local space budgets, not compact breakpoints. The app sidebar shrinks from its preferred 320px toward a 200px minimum to leave a 400px center target; its preferred width fits at 720px. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
|
||||
|
||||
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
|
||||
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf.
|
||||
|
||||
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
||||
|
||||
|
||||
@@ -382,6 +382,15 @@ grace. During daemon startup, plugin sessions may connect while application WebS
|
||||
paused; the daemon accepts clients only after configured plugins have settled and the initial
|
||||
catalog is complete.
|
||||
|
||||
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
|
||||
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
|
||||
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
|
||||
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
|
||||
passing the bundle string still costs memory and time once per client lifetime.
|
||||
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
|
||||
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
|
||||
in-memory bundles usable, and a later catalog retries persistence.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-dk7kJUdmQBF8L8Vkj6Ryza3n4/S+YqgxWY3R59Dc3mY=
|
||||
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37590,7 +37590,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39793,12 +39793,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.13.2",
|
||||
"@getpaseo/protocol": "0.13.2",
|
||||
"@getpaseo/server": "0.13.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/server": "0.14.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -40045,10 +40045,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.13.2",
|
||||
"@getpaseo/relay": "0.13.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -40060,7 +40060,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40304,7 +40304,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40350,7 +40350,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -40379,15 +40379,15 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.13.2",
|
||||
"@getpaseo/protocol": "0.13.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40397,8 +40397,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.13.2",
|
||||
"@getpaseo/protocol": "0.13.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40420,7 +40420,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40447,7 +40447,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
@@ -40668,16 +40668,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.13.2",
|
||||
"@getpaseo/highlight": "0.13.2",
|
||||
"@getpaseo/plugin": "0.13.2",
|
||||
"@getpaseo/protocol": "0.13.2",
|
||||
"@getpaseo/relay": "0.13.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/highlight": "0.14.0-beta.1",
|
||||
"@getpaseo/plugin": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41598,7 +41598,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43208,7 +43208,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
seedRealisticOlderTimeline,
|
||||
sendLiveTurnBeforeHydration,
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
expectToolCallGroupPositionPreserved,
|
||||
rememberToolCallGroupPosition,
|
||||
seedItemPagedToolGroupTimeline,
|
||||
useChatDisplay,
|
||||
userNavigatesTimelineToHistoryStartWithKeyboard,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
@@ -100,6 +104,32 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Hosts paged by items can end a page inside a tool run. The older page extends the group
|
||||
// the reader is on, which changes the row hosting it; the reader must stay with that group
|
||||
// rather than land at history start and drain every older page.
|
||||
test("keeps the reading position when an older item page extends the tool-call group being read", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await useChatDisplay(page, "collapseToolCalls");
|
||||
const agent = await seedItemPagedToolGroupTimeline();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true });
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads older history when the user navigates to history start with the keyboard", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -5,22 +5,86 @@ import {
|
||||
getTerminalBufferText,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { metroTest as test, expect } from "../support/fixtures";
|
||||
import { metroTest as test, expect, type Page } from "../support/fixtures";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectFoldedTurnAtReadingPosition,
|
||||
expectOldestLoadedTurnPartial,
|
||||
holdOlderHistoryPages,
|
||||
openAgentTimeline,
|
||||
expectTimelinePromptVisible,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
|
||||
interface PublishedDaemonAgent {
|
||||
serverId: string;
|
||||
daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
|
||||
agent: MockAgentWorkspace;
|
||||
/** Register the host in the browser and apply app settings before the first load. */
|
||||
seedBrowser(page: Page, appSettings?: Record<string, unknown>): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function startPublishedDaemonAgent(options: {
|
||||
publishedVersion: string;
|
||||
serverIdPrefix: string;
|
||||
repoPrefix: string;
|
||||
title: string;
|
||||
}): Promise<PublishedDaemonAgent> {
|
||||
const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
publishedVersion: options.publishedVersion,
|
||||
});
|
||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port });
|
||||
const createdAgent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: options.title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const agent: MockAgentWorkspace = {
|
||||
agentId: createdAgent.id,
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
client: workspace.client,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
return {
|
||||
serverId,
|
||||
daemon,
|
||||
agent,
|
||||
async seedBrowser(page, appSettings) {
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ seededHost, preferences, settings }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings },
|
||||
);
|
||||
},
|
||||
async close() {
|
||||
await agent.cleanup();
|
||||
await daemon.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
|
||||
const observationSockets = new Set<unknown>();
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
@@ -34,43 +98,18 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
observationSockets.add(socket);
|
||||
});
|
||||
});
|
||||
const workspace = await seedWorkspace({
|
||||
const published = await startPublishedDaemonAgent({
|
||||
publishedVersion: "0.2.5",
|
||||
serverIdPrefix: "srv_old_pagination_",
|
||||
repoPrefix: "timeline-old-daemon-pagination-",
|
||||
port: daemon.port,
|
||||
});
|
||||
const createdAgent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Published daemon pagination regression",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const agent: MockAgentWorkspace = {
|
||||
agentId: createdAgent.id,
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
client: workspace.client,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
const { agent, serverId } = published;
|
||||
|
||||
try {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ seededHost, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
await published.seedBrowser(page);
|
||||
|
||||
await openAgentTimeline(page, agent, serverId);
|
||||
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
|
||||
@@ -87,7 +126,7 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
|
||||
expect(terminal.error).toBeNull();
|
||||
const terminalId = terminal.terminal!.id;
|
||||
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
|
||||
const route = buildHostWorkspaceRoute(serverId, agent.workspaceId);
|
||||
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
|
||||
await focusTerminalSurface(page);
|
||||
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
|
||||
@@ -97,7 +136,46 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
await agent.client.killTerminal(terminalId);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
await daemon.close();
|
||||
await published.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn
|
||||
// starts mid-turn and only folds once the next page completes it; the reader must stay
|
||||
// with that turn instead of landing at history start and draining every older page.
|
||||
test("keeps the reader on the turn that folds when an older item page completes it", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
const published = await startPublishedDaemonAgent({
|
||||
publishedVersion: "0.13.0-beta.2",
|
||||
serverIdPrefix: "srv_old_fold_",
|
||||
repoPrefix: "timeline-old-daemon-fold-",
|
||||
title: "Published daemon folded turn pagination",
|
||||
});
|
||||
const { agent, serverId } = published;
|
||||
const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`);
|
||||
|
||||
try {
|
||||
for (const prompt of prompts) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
await published.seedBrowser(page, { chatDisplay: "collapseTurns" });
|
||||
const history = await holdOlderHistoryPages(page, agent, {
|
||||
daemonPort: published.daemon.port,
|
||||
});
|
||||
await openAgentTimeline(page, agent, serverId);
|
||||
await expectTimelinePromptVisible(page, prompts.at(-1)!);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
} finally {
|
||||
await published.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
withTimelinePlugin,
|
||||
viewInlineThinkingExample,
|
||||
@@ -6,6 +7,8 @@ import {
|
||||
interactWithStreamingCard,
|
||||
expectWholeCompletedCard,
|
||||
expectBothConsecutiveTools,
|
||||
trackPluginChatRecovery,
|
||||
reconnectPluginChat,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
|
||||
for (const width of [1100, 390]) {
|
||||
@@ -13,13 +16,24 @@ for (const width of [1100, 390]) {
|
||||
page,
|
||||
}, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await withTimelinePlugin(page, info, "assistant", async (agent) => {
|
||||
await requestPluginTimeline(agent);
|
||||
await interactWithStreamingCard(page);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expectWholeCompletedCard(page);
|
||||
// Local state is checked during growth; the viewport remounts on the history handoff.
|
||||
const recovery = trackPluginChatRecovery(page);
|
||||
gate.holdTimelineResponses(agent.agentId);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await recovery.expectCatalogWithoutRetransmission();
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expectWholeCompletedCard(page);
|
||||
await recovery.expectRecoveredWithoutRetransmission();
|
||||
recovery.reset();
|
||||
await reconnectPluginChat(page, gate);
|
||||
await recovery.expectRecoveredWithoutRetransmission();
|
||||
await expectWholeCompletedCard(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
@@ -214,3 +216,66 @@ test.describe("provider settings overlay stack", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function openTimedOutProvider(page: Page, workspaceId: string, width: number) {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
if (width < 600) {
|
||||
await page.getByTestId("agent-controls-model").click();
|
||||
} else {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
}
|
||||
await page.getByTestId("model-provider-picker-timeout").click();
|
||||
await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectErrorHasSideGutters(page: Page) {
|
||||
const error = page.getByText(/Timed out refreshing Pi timeout/);
|
||||
const bounds = await error.evaluate((element) => {
|
||||
const text = element.getBoundingClientRect();
|
||||
const state = element.parentElement!.getBoundingClientRect();
|
||||
return { left: text.left - state.left, right: state.right - text.right };
|
||||
});
|
||||
expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0);
|
||||
expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0);
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: {
|
||||
catalogRefreshTimeoutMs: 2000,
|
||||
providers: {
|
||||
"picker-timeout": {
|
||||
extends: "pi",
|
||||
label: "Pi timeout",
|
||||
enabled: true,
|
||||
command: [
|
||||
process.execPath,
|
||||
path.resolve("e2e/fixtures/fake-pi-rpc.mjs"),
|
||||
"--stall-model-discovery",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("model discovery timeout", () => {
|
||||
for (const width of [1200, 390]) {
|
||||
test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
const workspace = await withWorkspace({ prefix: "model-error-spacing-" });
|
||||
await openTimedOutProvider(page, workspace.workspaceId, width);
|
||||
await page.screenshot({ path: testInfo.outputPath("model-error.png") });
|
||||
await expectErrorHasSideGutters(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -479,3 +479,76 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expect(page.getByTestId("welcome-direct-connection")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
async function measureDockedNavigation(page: import("@playwright/test").Page, width: number) {
|
||||
await page.setViewportSize({ width, height: 951 });
|
||||
const history = page.getByRole("button", { name: /^History/ }).filter({ visible: true });
|
||||
const settings = page
|
||||
.getByRole("button", { name: "Settings", exact: true })
|
||||
.filter({ visible: true });
|
||||
await expect(history).toBeVisible();
|
||||
await expect(settings).toBeVisible();
|
||||
return {
|
||||
width,
|
||||
history: await history.evaluate((el) => el.getBoundingClientRect().height),
|
||||
settings: await settings.evaluate((el) => el.getBoundingClientRect().height),
|
||||
};
|
||||
}
|
||||
|
||||
async function measureSettingsSplit(page: import("@playwright/test").Page, width: number) {
|
||||
await page.setViewportSize({ width, height: 951 });
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const detail = page.getByTestId("settings-detail-pane");
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(detail).toBeVisible();
|
||||
return {
|
||||
width,
|
||||
sidebar: await sidebar.evaluate((el) => el.getBoundingClientRect().width),
|
||||
detail: await detail.evaluate((el) => el.getBoundingClientRect().width),
|
||||
};
|
||||
}
|
||||
|
||||
async function expectConsistentDockedNavigation(page: import("@playwright/test").Page) {
|
||||
const wide = await measureDockedNavigation(page, 951);
|
||||
for (const width of [600, 669, 719, 720]) {
|
||||
const narrow = await measureDockedNavigation(page, width);
|
||||
expect
|
||||
.soft(narrow.history, `History keeps its wide-layout density at ${width}`)
|
||||
.toBeCloseTo(wide.history, 0);
|
||||
expect
|
||||
.soft(narrow.settings, `Settings keeps its wide-layout density at ${width}`)
|
||||
.toBeCloseTo(wide.settings, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSettingsDetailKeepsReadingWidth(page: import("@playwright/test").Page) {
|
||||
const reference = await measureSettingsSplit(page, 720);
|
||||
for (const width of [600, 669, 719]) {
|
||||
const narrow = await measureSettingsSplit(page, width);
|
||||
expect
|
||||
.soft(narrow.detail, `Settings preserves its detail budget at ${width}`)
|
||||
.toBeGreaterThanOrEqual(reference.detail);
|
||||
expect
|
||||
.soft(narrow.sidebar, `Settings navigation yields width at ${width}`)
|
||||
.toBeLessThan(reference.sidebar);
|
||||
}
|
||||
const wide = await measureSettingsSplit(page, 951);
|
||||
expect(wide.sidebar).toBeCloseTo(reference.sidebar, 0);
|
||||
expect(wide.detail).toBeGreaterThan(reference.detail);
|
||||
}
|
||||
|
||||
test("docked navigation keeps its density and Settings preserves reading width", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "narrow-wide-layout-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await expectConsistentDockedNavigation(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectAppearanceContent(page);
|
||||
await expectSettingsDetailKeepsReadingWidth(page);
|
||||
await page.setViewportSize({ width: 599, height: 951 });
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await expectAppearanceContent(page);
|
||||
});
|
||||
|
||||
@@ -193,6 +193,7 @@ test("received messages show their sender and open that agent", async ({ page })
|
||||
.getByText("Sent by Reviewer", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor);
|
||||
await expectSenderAlignedWithBubble(attribution);
|
||||
await attribution.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: "/tmp/sender-attribution.png" });
|
||||
await attribution.click();
|
||||
@@ -202,6 +203,135 @@ test("received messages show their sender and open that agent", async ({ page })
|
||||
}
|
||||
});
|
||||
|
||||
interface MessagePreviewWatch {
|
||||
/** Frames after the watch started on which the fade or "Show more" control was missing. */
|
||||
framesWithoutClip: number[];
|
||||
/** Frames on which the fade was a different DOM node than when the watch started. */
|
||||
framesRemounted: number[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__paseoMessagePreviewWatch?: { stop(): MessagePreviewWatch };
|
||||
}
|
||||
}
|
||||
|
||||
async function seedReceivedReviewWithToolTurn() {
|
||||
const recipient = await seedMockAgentWorkspace({
|
||||
repoPrefix: "preview-clip-",
|
||||
title: "Implementation",
|
||||
});
|
||||
const sender = await recipient.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: recipient.cwd,
|
||||
workspaceId: recipient.workspaceId,
|
||||
title: "Reviewer",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
// Long enough to clip behind the preview fade; the instruction makes the recipient run tools.
|
||||
await recipient.client.sendAgentMessage(
|
||||
recipient.agentId,
|
||||
"Review complete: the regression is red on the baseline and green with the fix.\n" +
|
||||
"Keep the previous response visible while the reader scrolls.\n".repeat(30) +
|
||||
"Now emit 7 tool agent stream updates",
|
||||
{ sourceAgentId: sender.id },
|
||||
);
|
||||
return recipient;
|
||||
}
|
||||
|
||||
/** Samples the received message's bubble on every animation frame until stopped. */
|
||||
async function watchReceivedMessagePreview(page: Page, senderLabel: string): Promise<void> {
|
||||
await page.evaluate((label) => {
|
||||
const findBubble = () =>
|
||||
[...document.querySelectorAll("[data-testid=user-message]")].find((row) =>
|
||||
row.textContent?.includes(label),
|
||||
) ?? null;
|
||||
const initialFade = findBubble()?.querySelector("svg") ?? null;
|
||||
if (!initialFade) throw new Error(`No faded bubble for ${label}`);
|
||||
const framesWithoutClip: number[] = [];
|
||||
const framesRemounted: number[] = [];
|
||||
let frame = 0;
|
||||
let running = true;
|
||||
const sample = () => {
|
||||
if (!running) return;
|
||||
const bubble = findBubble();
|
||||
const fade = bubble?.querySelector("svg") ?? null;
|
||||
if (!fade || !bubble?.textContent?.includes("Show more")) framesWithoutClip.push(frame);
|
||||
else if (fade !== initialFade) framesRemounted.push(frame);
|
||||
frame += 1;
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
window.__paseoMessagePreviewWatch = {
|
||||
stop() {
|
||||
running = false;
|
||||
return { framesWithoutClip, framesRemounted };
|
||||
},
|
||||
};
|
||||
}, senderLabel);
|
||||
}
|
||||
|
||||
async function expectReceivedMessagePreviewUndisturbed(page: Page): Promise<void> {
|
||||
// Give the opened turn's layout a few frames to settle before judging.
|
||||
await page.evaluate(
|
||||
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
|
||||
);
|
||||
const watch = await page.evaluate(() => window.__paseoMessagePreviewWatch!.stop());
|
||||
expect(
|
||||
watch.framesWithoutClip,
|
||||
"the preview fade and Show more control stay on every frame",
|
||||
).toEqual([]);
|
||||
expect(watch.framesRemounted, "the preview keeps its DOM node (no remount)").toEqual([]);
|
||||
}
|
||||
|
||||
// Opening a turn must not rebuild its host row: the preview above the summary loses its
|
||||
// measured height when remounted, which drops the fade and "Show more" for a frame.
|
||||
test("opening a turn keeps the received message above it clipped on every frame", async ({
|
||||
page,
|
||||
}) => {
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const recipient = await seedReceivedReviewWithToolTurn();
|
||||
try {
|
||||
await openAgentRoute(page, recipient);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByText("Completed 7 commands", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const bubble = page
|
||||
.getByTestId("user-message")
|
||||
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
|
||||
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
|
||||
await bubble.scrollIntoViewIfNeeded();
|
||||
|
||||
await watchReceivedMessagePreview(page, "Sent by Reviewer");
|
||||
await page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 7 commands", exact: true })
|
||||
.click();
|
||||
await expect(page.getByText("echo stress-6", { exact: true })).toBeVisible();
|
||||
await expectReceivedMessagePreviewUndisturbed(page);
|
||||
} finally {
|
||||
await recipient.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectSenderAlignedWithBubble(attribution: Locator) {
|
||||
const edges = await attribution.evaluate((button) => {
|
||||
const bubble = button.nextElementSibling;
|
||||
const label = button.querySelector('[dir="auto"]');
|
||||
if (!bubble || !label) throw new Error("Sender label or message bubble missing");
|
||||
return {
|
||||
labelRight: label.getBoundingClientRect().right,
|
||||
bubbleRight: bubble.getBoundingClientRect().right,
|
||||
};
|
||||
});
|
||||
expect(
|
||||
Math.abs(edges.labelRight - edges.bubbleRight),
|
||||
"sender and bubble share the right edge",
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
async function seedWholeTurnHistory() {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "whole-turn-history-",
|
||||
@@ -301,6 +431,7 @@ for (const autoExpandReasoning of [false, true]) {
|
||||
await expectComposerVisible(page);
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
await expect(summary).toBeVisible();
|
||||
await expect(summary).not.toContainText(/thought/i);
|
||||
await expect(reasoning).toHaveCount(0);
|
||||
await summary.click();
|
||||
await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0);
|
||||
@@ -339,11 +470,25 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
|
||||
await expect(reasoning).toHaveCount(0);
|
||||
const group = page
|
||||
.getByTestId("tool-call-group")
|
||||
.filter({ hasText: /thought/, visible: true })
|
||||
.filter({ hasText: /read 1 file/i, visible: true })
|
||||
.last();
|
||||
await expect(group).toContainText(/read 1 file/i);
|
||||
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
|
||||
await group.getByRole("button").first().click();
|
||||
await expect(reasoning).toBeVisible();
|
||||
// Expand thinking is off: the group opens its rows, but the thought row stays closed
|
||||
// until the reader opens it.
|
||||
const thought = group
|
||||
.getByTestId("tool-call-badge")
|
||||
.filter({ hasText: /^Thinking/ })
|
||||
.first();
|
||||
await expect(thought).toBeVisible();
|
||||
await expect(reasoning).toHaveCount(0);
|
||||
// A press right after the group opens occasionally does not register; retry until the
|
||||
// thought row opens.
|
||||
await expect(async () => {
|
||||
await thought.getByRole("button").first().click();
|
||||
await expect(reasoning).toBeVisible({ timeout: 2_000 });
|
||||
}).toPass();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ for await (const line of lines) {
|
||||
const command = JSON.parse(line);
|
||||
let data = {};
|
||||
if (command.type === "get_available_models") {
|
||||
if (process.argv.includes("--stall-model-discovery")) continue;
|
||||
data = {
|
||||
models: [
|
||||
{
|
||||
|
||||
@@ -338,11 +338,32 @@ export async function delayAgentOlderTimelineResponse(
|
||||
return delayAgentTimelineResponse(page, agentId, "before");
|
||||
}
|
||||
|
||||
export interface OlderTimelinePagesOptions {
|
||||
daemonPort?: number;
|
||||
/**
|
||||
* Hide whole-turn paging from the app so it reads this host with item pages, as it reads
|
||||
* daemons released before whole-turn pages. An item page can end inside a turn or a tool run.
|
||||
*/
|
||||
itemPagedHost?: boolean;
|
||||
}
|
||||
|
||||
function withoutWholeTurnPages(message: WebSocketMessage): WebSocketMessage {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (sessionMessage?.type !== "status" || payload?.status !== "server_info") return message;
|
||||
const envelope = parseWebSocketJson(message) as {
|
||||
message: { payload: { features?: Record<string, unknown> } };
|
||||
};
|
||||
delete envelope.message.payload.features?.wholeTurnTimeline;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
export async function holdAgentOlderTimelinePages(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
daemonPort?: number,
|
||||
options: OlderTimelinePagesOptions = {},
|
||||
): Promise<OlderTimelinePagesGate> {
|
||||
const { daemonPort } = options;
|
||||
let requestCount = 0;
|
||||
let responseCount = 0;
|
||||
let repeatedEntryCount = 0;
|
||||
@@ -376,7 +397,8 @@ export async function holdAgentOlderTimelinePages(
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
server.onMessage((received) => {
|
||||
const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received;
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
|
||||
@@ -5,8 +5,64 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
import { expectReconnectingToastVisible } from "./workspace-ui";
|
||||
|
||||
const PLUGIN_ID = "timeline-transform-e2e";
|
||||
|
||||
/** Observe the app's real socket, including fresh sockets after a reload. */
|
||||
export function trackPluginChatRecovery(page: Page) {
|
||||
const messages: {
|
||||
type: string;
|
||||
payload?: { plugins?: { id: string; clientBundle?: string }[] };
|
||||
}[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
const collect = ({ payload }: { payload: string | Buffer }) => {
|
||||
if (typeof payload !== "string") return;
|
||||
const frame = JSON.parse(payload);
|
||||
if (frame.type === "session") messages.push(frame.message);
|
||||
};
|
||||
socket.on("framesent", collect);
|
||||
socket.on("framereceived", collect);
|
||||
});
|
||||
return {
|
||||
reset() {
|
||||
messages.length = 0;
|
||||
},
|
||||
async expectCatalogWithoutRetransmission() {
|
||||
await expect
|
||||
.poll(() =>
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "plugin.catalog.get.response" &&
|
||||
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
const entry = messages
|
||||
.find((message) => message.type === "plugin.catalog.get.response")
|
||||
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry).not.toHaveProperty("clientBundle");
|
||||
},
|
||||
async expectRecoveredWithoutRetransmission() {
|
||||
await this.expectCatalogWithoutRetransmission();
|
||||
await expect
|
||||
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
|
||||
.toBe(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function reconnectPluginChat(
|
||||
page: Page,
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
) {
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
gate.restore();
|
||||
}
|
||||
export const ASSISTANT_TEXT =
|
||||
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type TestInfo, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type TestInfo, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { readReplicaCache } from "./replica-cache-storage";
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type AgentTimelineResponseGate,
|
||||
type BootstrapTimelineGate,
|
||||
type OlderTimelinePagesGate,
|
||||
type OlderTimelinePagesOptions,
|
||||
} from "./agent-timeline-gate";
|
||||
|
||||
export { holdDaemonHydration };
|
||||
@@ -35,6 +36,19 @@ interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
|
||||
newestPrompt: string;
|
||||
}
|
||||
|
||||
export interface ItemPagedToolGroupAgent extends MockAgentWorkspace {
|
||||
newestPrompt: string;
|
||||
/** Commands in one run, more than an item page holds. */
|
||||
totalCommands: number;
|
||||
/** Commands of that run the initial tail loads, so the size of the group as first shown. */
|
||||
loadedCommands: number;
|
||||
}
|
||||
|
||||
interface ToolCallGroupPositionSnapshot {
|
||||
top: number;
|
||||
}
|
||||
|
||||
const ITEM_PAGE_SIZE = 40;
|
||||
const PROMPT_PREFIX = "timeline-pagination-turn";
|
||||
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
@@ -126,6 +140,93 @@ export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimeli
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A run of commands longer than an item page, followed by enough short turns that the
|
||||
* initial tail ends inside the run. Read with item pages, the tail loads the newest
|
||||
* commands as one group; the older page carries the rest of that run and the turns before
|
||||
* it, with a further page behind it.
|
||||
*/
|
||||
export async function seedItemPagedToolGroupTimeline(): Promise<ItemPagedToolGroupAgent> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-item-page-tool-group-",
|
||||
title: "Item page tool group regression",
|
||||
});
|
||||
const olderTurns = 10;
|
||||
const newerTurns = 12;
|
||||
const totalCommands = 50;
|
||||
// A short turn is its prompt and one reply; the run's turn ends with one reply.
|
||||
const loadedCommands = ITEM_PAGE_SIZE - newerTurns * 2 - 1;
|
||||
const shortTurnPrompt = (label: string, index: number) =>
|
||||
`item-page-${label}-turn-${index}: emit 1 coalesced agent stream updates`;
|
||||
const sendTurn = async (prompt: string) => {
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
};
|
||||
|
||||
for (let index = 0; index < olderTurns; index += 1) {
|
||||
await sendTurn(shortTurnPrompt("older", index));
|
||||
}
|
||||
await sendTurn(`emit ${totalCommands} tool agent stream updates`);
|
||||
for (let index = 0; index < newerTurns; index += 1) {
|
||||
await sendTurn(shortTurnPrompt("newer", index));
|
||||
}
|
||||
|
||||
return {
|
||||
...agent,
|
||||
newestPrompt: shortTurnPrompt("newer", newerTurns - 1),
|
||||
totalCommands,
|
||||
loadedCommands,
|
||||
};
|
||||
}
|
||||
|
||||
export async function useChatDisplay(
|
||||
page: Page,
|
||||
chatDisplay: "full" | "collapseToolCalls" | "collapseTurns",
|
||||
): Promise<void> {
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
|
||||
}, chatDisplay);
|
||||
}
|
||||
|
||||
function toolCallGroup(page: Page, commandCount: number): Locator {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
return timeline
|
||||
.getByTestId("tool-call-group")
|
||||
.getByRole("button", { name: `Ran ${commandCount} commands`, exact: true });
|
||||
}
|
||||
|
||||
export async function rememberToolCallGroupPosition(
|
||||
page: Page,
|
||||
commandCount: number,
|
||||
): Promise<ToolCallGroupPositionSnapshot> {
|
||||
const group = toolCallGroup(page, commandCount);
|
||||
await expect(group).toBeVisible();
|
||||
const box = await group.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Expected a rendered tool-call group of ${commandCount} commands`);
|
||||
}
|
||||
return { top: box.y };
|
||||
}
|
||||
|
||||
/** The group the reader was on keeps its place on screen, now counting the older page's calls. */
|
||||
export async function expectToolCallGroupPositionPreserved(
|
||||
page: Page,
|
||||
commandCount: number,
|
||||
before: ToolCallGroupPositionSnapshot,
|
||||
): Promise<void> {
|
||||
const group = toolCallGroup(page, commandCount);
|
||||
await expect(group).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const box = await group.boundingBox();
|
||||
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
|
||||
},
|
||||
{ message: `the group of ${commandCount} commands keeps its viewport position` },
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
export async function scrollThroughOlderHistoryPages(
|
||||
page: Page,
|
||||
pageCount: number,
|
||||
@@ -316,13 +417,14 @@ export async function holdNextOlderTimelinePage(
|
||||
export async function holdOlderHistoryPages(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
daemonPort?: number,
|
||||
options?: OlderTimelinePagesOptions,
|
||||
): Promise<OlderHistoryPages> {
|
||||
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
|
||||
page,
|
||||
agent.agentId,
|
||||
daemonPort,
|
||||
options,
|
||||
);
|
||||
const loader = page.getByTestId("load-older-history-spinner");
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
@@ -330,7 +432,17 @@ export async function holdOlderHistoryPages(
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
// The loader stays until the prepend settles and the viewport decides whether to
|
||||
// keep loading. Once it hides, no further page loads without new user intent.
|
||||
await expect
|
||||
.poll(
|
||||
async () => ({
|
||||
requestedPages: gate.getRequestCount(),
|
||||
loading: await loader.isVisible(),
|
||||
}),
|
||||
{ message: `older history settles after ${count} requested page(s)` },
|
||||
)
|
||||
.toEqual({ requestedPages: count, loading: false });
|
||||
},
|
||||
expectNoRepeatedEntries() {
|
||||
expect(gate.getRepeatedEntryCount()).toBe(0);
|
||||
@@ -423,6 +535,53 @@ export async function expectTimelineViewportAnchoredAfterPrepend(
|
||||
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item-based pages from older hosts split turns, so the oldest loaded turn starts
|
||||
* mid-turn: its prompt is above loaded history. Returns that prompt.
|
||||
*/
|
||||
export async function expectOldestLoadedTurnPartial(
|
||||
page: Page,
|
||||
prompts: readonly string[],
|
||||
): Promise<string> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const oldestLoadedPrompt = await timeline
|
||||
.getByTestId("user-message")
|
||||
.first()
|
||||
.evaluate((element) => element.textContent ?? "");
|
||||
const loadedIndex = prompts.findIndex((prompt) => oldestLoadedPrompt.includes(prompt));
|
||||
expect(loadedIndex, "oldest loaded prompt is a seeded turn").toBeGreaterThan(0);
|
||||
const oldestRow = timeline.locator("[data-history-row-id]").first();
|
||||
await expect(oldestRow.getByTestId("user-message"), "rows above the oldest prompt").toHaveCount(
|
||||
0,
|
||||
);
|
||||
const partialPrompt = prompts[loadedIndex - 1]!;
|
||||
await expectTimelinePromptNotMounted(page, partialPrompt);
|
||||
return partialPrompt;
|
||||
}
|
||||
|
||||
/** The reader stays with the turn they were reading once it folds behind its summary. */
|
||||
export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: string): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
|
||||
await expect(promptRow).toBeVisible({ timeout: 15_000 });
|
||||
// The host row carries the folded turn's summary line.
|
||||
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const [timelineBox, promptBox] = await Promise.all([
|
||||
timeline.boundingBox(),
|
||||
promptRow.boundingBox(),
|
||||
]);
|
||||
if (!timelineBox || !promptBox) return false;
|
||||
const offset = promptBox.y - timelineBox.y;
|
||||
return offset >= 0 && offset <= timelineBox.height / 2;
|
||||
},
|
||||
{ message: "folded turn sits in the upper half of the viewport" },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { createReadingAnchor } from "./reading-anchor";
|
||||
|
||||
const runtime = {
|
||||
paseo: {},
|
||||
@@ -609,8 +610,54 @@ it("opening a turn reveals individual calls rather than another tool group", ()
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
|
||||
expect(result.groupsByHostId.size).toBe(0);
|
||||
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["collapseToolCalls", "collapseTurns"] as const)(
|
||||
"preserves the reader when a prepend extends a group in %s",
|
||||
(display) => {
|
||||
const present = createStreamPresentation();
|
||||
const anchor = createReadingAnchor();
|
||||
const calls = [toolCall("one", "Bash"), toolCall("two", "Bash")];
|
||||
const input = {
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
display,
|
||||
head: [],
|
||||
isTurnActive: false,
|
||||
};
|
||||
const partial = present({ ...input, tail: calls.slice(1) });
|
||||
anchor.reconcile(0, [{ id: partial.tail[0]!.id, top: 48, height: 200 }]);
|
||||
const completed = present({ ...input, tail: calls });
|
||||
const groupId = completed.tail[0]!.id;
|
||||
expect(anchor.getRowId(completed.readingRowReplacements)).toBe(groupId);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
0,
|
||||
[{ id: groupId, top: 448, height: 200 }],
|
||||
false,
|
||||
completed.readingRowReplacements,
|
||||
),
|
||||
).toBe(400);
|
||||
expect(anchor.getRowId()).toBe(groupId);
|
||||
const timestamp = new Date();
|
||||
const folded = present({
|
||||
...input,
|
||||
display: "collapseTurns",
|
||||
tail: [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
...calls,
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
],
|
||||
});
|
||||
// Both the original partial group and its replacement resolve directly to the turn.
|
||||
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
|
||||
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
|
||||
const opened = present({ ...input, tail: calls, display: "full" });
|
||||
expect(opened.readingRowReplacements.get(calls[1]!.id)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("presents received agent messages as attributed bubbles with the original turn identity", () => {
|
||||
const call = toolCall("received", "agent_message");
|
||||
if (call.payload.source !== "agent") throw new Error("Expected agent tool");
|
||||
@@ -655,6 +702,28 @@ it("does not reopen the previous turn while a new run waits for its first row",
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
|
||||
// the completed one; it must stay folded until the new turn's rows arrive.
|
||||
it("keeps the previous turn folded while a sent prompt waits for its turn to open", () => {
|
||||
const timestamp = new Date();
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
];
|
||||
for (const item of tail) item.turnId = "completed";
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseTurns",
|
||||
isTurnActive: true,
|
||||
activeTurnId: null,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
|
||||
const present = createStreamPresentation();
|
||||
const timestamp = new Date();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
|
||||
import type { ReadingRowReplacements } from "./reading-anchor";
|
||||
|
||||
interface PresentationInput {
|
||||
tail: StreamItem[];
|
||||
@@ -26,6 +27,22 @@ interface PresentationInput {
|
||||
|
||||
export interface StreamPresentation extends ToolCallDetailProjection {
|
||||
turns: Omit<TurnCollapse, "tail">;
|
||||
readingRowReplacements: ReadingRowReplacements;
|
||||
}
|
||||
|
||||
function groupedRowHosts(
|
||||
rows: readonly StreamItem[],
|
||||
groups: ToolCallDetailProjection["groupsByHostId"],
|
||||
): ReadonlyMap<string, string> {
|
||||
const hosts = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const group = groups.get(row.id);
|
||||
if (!group) continue;
|
||||
for (const call of group.run.calls) {
|
||||
if (call.id !== row.id) hosts.set(call.id, row.id);
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
||||
@@ -61,6 +78,7 @@ export function createStreamPresentation() {
|
||||
let preparedAutoExpandReasoning: boolean | undefined;
|
||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||
let historyGroupHosts: ReadonlyMap<string, string> = new Map();
|
||||
let collapsedSource: StreamItem[] | undefined;
|
||||
let collapsedIsLatestTurnOpen = false;
|
||||
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
|
||||
@@ -89,20 +107,22 @@ export function createStreamPresentation() {
|
||||
function collapse(
|
||||
projection: ToolCallDetailProjection,
|
||||
input: PresentationInput,
|
||||
): StreamPresentation {
|
||||
): Omit<StreamPresentation, "readingRowReplacements"> {
|
||||
if (input.display !== "collapseTurns") {
|
||||
return { ...projection, turns: NO_COLLAPSED_TURNS };
|
||||
}
|
||||
// Live text updates reach the head only, so the folded history is reused until the
|
||||
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
|
||||
const isLatestTurnOpen =
|
||||
(input.isTurnActive || projection.head.length > 0) &&
|
||||
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
|
||||
!(
|
||||
input.activeTurnId &&
|
||||
projection.tail.at(-1)?.turnId &&
|
||||
input.activeTurnId !== projection.tail.at(-1)?.turnId
|
||||
);
|
||||
const last = projection.tail.at(-1) ?? null;
|
||||
const first = projection.head[0] ?? null;
|
||||
const activeTurnIsAnother = Boolean(
|
||||
input.activeTurnId && last?.turnId && input.activeTurnId !== last.turnId,
|
||||
);
|
||||
// Without head rows, the latest retained turn is live only while the active run is that
|
||||
// turn. A run that has not opened yet carries no id and belongs to the next turn.
|
||||
const isLatestTurnOpen = first
|
||||
? !isTurnBoundary(last, first) && !activeTurnIsAnother
|
||||
: input.isTurnActive && (last?.turnId === undefined || input.activeTurnId === last.turnId);
|
||||
if (
|
||||
!collapsed ||
|
||||
collapsedSource !== projection.tail ||
|
||||
@@ -157,19 +177,42 @@ export function createStreamPresentation() {
|
||||
preparedAutoExpandReasoning !== input.autoExpandReasoning
|
||||
) {
|
||||
preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning);
|
||||
historyGroupHosts = preparedHistory
|
||||
? groupedRowHosts(preparedHistory.grouped.tail, preparedHistory.grouped.groupsByHostId)
|
||||
: new Map();
|
||||
preparedTail = historyRows;
|
||||
preparedLevel = level;
|
||||
preparedAutoExpandReasoning = input.autoExpandReasoning;
|
||||
}
|
||||
return collapse(
|
||||
projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
}),
|
||||
input,
|
||||
const projection = projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
});
|
||||
const result = collapse(projection, input);
|
||||
// Only the trailing retained group can absorb head rows. Reuse the retained
|
||||
// index across streaming updates rather than walking the full history again.
|
||||
const last = projection.tail.at(-1);
|
||||
const liveGroupHosts = groupedRowHosts(
|
||||
last ? [last, ...projection.head] : projection.head,
|
||||
projection.groupsByHostId,
|
||||
);
|
||||
const retainedGroupHosts = historyGroupHosts;
|
||||
return {
|
||||
...result,
|
||||
readingRowReplacements: {
|
||||
get(id) {
|
||||
if (result.turns.nestedItemIds.has(id)) return undefined;
|
||||
const groupHost = liveGroupHosts.get(id) ?? retainedGroupHosts.get(id) ?? id;
|
||||
return (
|
||||
result.turns.hostIdByHiddenRowId.get(id) ??
|
||||
result.turns.hostIdByHiddenRowId.get(groupHost) ??
|
||||
(groupHost === id ? undefined : groupHost)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
|
||||
import { createReadingAnchor } from "./reading-anchor";
|
||||
|
||||
describe("reading anchor", () => {
|
||||
it("pins the folded turn host when a prepend replaces the row being read", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]);
|
||||
const replacements = new Map([["tool", "prompt"]]);
|
||||
expect(anchor.getRowId(replacements)).toBe("prompt");
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000);
|
||||
// Projection does not consume the correction; an unmounted host keeps the pin.
|
||||
expect(anchor.reconcile(0, [], false, replacements)).toBe(0);
|
||||
expect(anchor.getRowId()).toBe("tool");
|
||||
expect(
|
||||
anchor.reconcile(0, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
|
||||
).toBe(1000);
|
||||
expect(anchor.getRowId()).toBe("prompt");
|
||||
expect(
|
||||
anchor.reconcile(1000, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
|
||||
).toBe(1000);
|
||||
});
|
||||
|
||||
const rows = [
|
||||
{ id: "above", top: 0, height: 500 },
|
||||
{ id: "reading", top: 500, height: 500 },
|
||||
|
||||
@@ -6,6 +6,10 @@ interface RowGeometry {
|
||||
|
||||
// A row must clear the reading line before the next row takes ownership.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
export interface ReadingRowReplacements {
|
||||
get(id: string): string | undefined;
|
||||
}
|
||||
const NO_REPLACEMENTS: ReadingRowReplacements = new Map();
|
||||
|
||||
// Content coordinates survive user scrolling. Layout commits replace the geometry;
|
||||
// scroll events reuse it, without another DOM measurement pass.
|
||||
@@ -14,10 +18,15 @@ export function createReadingAnchor() {
|
||||
let geometry: readonly RowGeometry[] = [];
|
||||
const readingRow = (scrollTop: number) =>
|
||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) =>
|
||||
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0);
|
||||
const getRowId = (replacements = NO_REPLACEMENTS) =>
|
||||
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
|
||||
const project = (
|
||||
scrollTop: number,
|
||||
row: Pick<RowGeometry, "id" | "top"> | undefined,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
|
||||
return {
|
||||
getRowId: () => anchor?.id ?? null,
|
||||
getRowId,
|
||||
getReadingRowId: (scrollTop: number) =>
|
||||
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
|
||||
project,
|
||||
@@ -28,13 +37,18 @@ export function createReadingAnchor() {
|
||||
const next = readingRow(scrollTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
},
|
||||
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number {
|
||||
reconcile(
|
||||
scrollTop: number,
|
||||
rows: readonly RowGeometry[],
|
||||
userScrolled = false,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
): number {
|
||||
geometry = rows;
|
||||
const previous = anchor && rows.find((row) => row.id === anchor?.id);
|
||||
const previous = anchor && rows.find((row) => row.id === getRowId(replacements));
|
||||
// The first virtualized commit can precede its mounted range. Keep the
|
||||
// pinned reader until it mounts, rather than adopting an unrelated row.
|
||||
if (anchor && !previous && !userScrolled) return scrollTop;
|
||||
const correctedTop = project(scrollTop, previous ?? undefined);
|
||||
const correctedTop = project(scrollTop, previous ?? undefined, replacements);
|
||||
// A prepend can expose the bottom of an estimated row above the reader.
|
||||
// Do not transfer ownership to it until the user moves the reading position.
|
||||
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
|
||||
|
||||
@@ -394,7 +394,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const rangeExtractor = useCallback(
|
||||
(range: VirtualRange): number[] => {
|
||||
const anchorIndex = segments.historyVirtualized.findIndex(
|
||||
(row) => row.id === readingAnchor.getRowId(),
|
||||
(row) => row.id === readingAnchor.getRowId(props.readingRowReplacements),
|
||||
);
|
||||
const container = scrollContainerRef.current;
|
||||
// TanStack has computed the new measurements before calling the extractor.
|
||||
@@ -404,10 +404,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const measurement = virtualizer.measurementsCache[anchorIndex];
|
||||
const correctedOffset =
|
||||
container && measurement
|
||||
? readingAnchor.project(container.scrollTop, {
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
})
|
||||
? readingAnchor.project(
|
||||
container.scrollTop,
|
||||
{
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
},
|
||||
props.readingRowReplacements,
|
||||
)
|
||||
: container?.scrollTop;
|
||||
let visibleRange = range;
|
||||
if (
|
||||
@@ -428,7 +432,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]),
|
||||
].sort((left, right) => left - right);
|
||||
},
|
||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized],
|
||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements],
|
||||
);
|
||||
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
|
||||
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
|
||||
@@ -742,7 +746,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
|
||||
if (!shouldAnchor) readingAnchor.reset();
|
||||
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows);
|
||||
const correctedTop = readingAnchor.reconcile(
|
||||
container.scrollTop,
|
||||
rows,
|
||||
false,
|
||||
props.readingRowReplacements,
|
||||
);
|
||||
if (!shouldAnchor) {
|
||||
readingAnchor.reset();
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { ReadingRowReplacements } from "./reading-anchor";
|
||||
import { continuesResponse } from "./turn-membership";
|
||||
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
|
||||
import type {
|
||||
@@ -78,6 +79,8 @@ export interface StreamRenderInput {
|
||||
segments: StreamRenderSegments;
|
||||
historyRowRevision?: StreamHistoryRowRevision;
|
||||
liveHeadRowRevision?: unknown;
|
||||
/** Presentation rows replaced by another row after history is folded. */
|
||||
readingRowReplacements?: ReadingRowReplacements;
|
||||
boundary: StreamHistoryBoundary;
|
||||
renderers: StreamSegmentRenderers;
|
||||
listEmptyComponent: ReactNode;
|
||||
|
||||
@@ -48,6 +48,14 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
|
||||
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
||||
|
||||
describe("collapseTurns", () => {
|
||||
it("maps hidden rows only while their turn is closed", () => {
|
||||
const result = collapse([user("u1"), shell("c1"), reply("final")]);
|
||||
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
|
||||
const expanded = collapse([user("u1"), shell("c1"), reply("final")], {
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
expect(expanded.hostIdByHiddenRowId.size).toBe(0);
|
||||
});
|
||||
it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => {
|
||||
const create = shell("create");
|
||||
if (create.payload.source !== "agent") throw new Error("Expected agent tool");
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface TurnCollapse {
|
||||
nestedItemIds: ReadonlySet<string>;
|
||||
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
|
||||
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
|
||||
hostIdByHiddenRowId: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export interface CollapseTurnsInput {
|
||||
@@ -80,6 +82,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const turnsByHostId = new Map<string, CollapsedTurn>();
|
||||
const nestedItemIds = new Set<string>();
|
||||
const hostIdByFoldedMessageId = new Map<string, string>();
|
||||
const hostIdByHiddenRowId = new Map<string, string>();
|
||||
|
||||
let runStart = 0;
|
||||
const flushRun = (runEnd: number) => {
|
||||
@@ -114,6 +117,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
if (staysVisibleWhenCollapsed(item)) continue;
|
||||
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
|
||||
if (expanded) nestedItemIds.add(item.id);
|
||||
else hostIdByHiddenRowId.set(item.id, host.id);
|
||||
}
|
||||
output.push(
|
||||
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
|
||||
@@ -139,6 +143,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
turnsByHostId: EMPTY_TURNS,
|
||||
nestedItemIds: EMPTY_IDS,
|
||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||
hostIdByHiddenRowId: EMPTY_HOSTS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -149,5 +154,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
turnsByHostId,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
hostIdByHiddenRowId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
|
||||
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
|
||||
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
|
||||
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
type WorkspaceFileLocation,
|
||||
@@ -459,6 +460,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
|
||||
const inlinePathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
@@ -466,8 +468,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
openOutsideDirectory: openInlineOutsideDirectory,
|
||||
}),
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory],
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isActive) inlinePathOpener.cancel();
|
||||
@@ -895,12 +898,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
? group.run.calls.map((call, index) => (
|
||||
<React.Fragment key={call.id}>
|
||||
{call.kind === "thought" ? (
|
||||
// Reasoning is grouped only while Expand thinking is off, so an opened
|
||||
// group shows each thought as a closed row like any other call.
|
||||
<ThoughtSlot
|
||||
itemId={call.id}
|
||||
text={call.text}
|
||||
status={call.status}
|
||||
isLastInSequence={index === group.run.calls.length - 1}
|
||||
defaultExpanded
|
||||
defaultExpanded={false}
|
||||
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
||||
/>
|
||||
) : (
|
||||
@@ -1203,6 +1208,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
<AssistantSelectionCopySurface style={stylesheet.container}>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
{streamRenderStrategy.render({
|
||||
readingRowReplacements: presentation.readingRowReplacements,
|
||||
agentId,
|
||||
segments: renderModel.segments,
|
||||
historyRowRevision,
|
||||
@@ -1895,12 +1901,11 @@ function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapper
|
||||
const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]);
|
||||
switch (turnIndent) {
|
||||
case "none":
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
case "openedHost":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
{children}
|
||||
<IndentRail style={gapStyle} />
|
||||
{turnIndent === "openedHost" ? <IndentRail style={gapStyle} /> : null}
|
||||
</View>
|
||||
);
|
||||
case "nested":
|
||||
|
||||
@@ -71,6 +71,9 @@ it("ignores an older probe after a newer file link opens", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
|
||||
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
|
||||
@@ -95,6 +98,9 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
|
||||
opener.cancel();
|
||||
@@ -102,3 +108,25 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
await pending;
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends folders outside the workspace away from the workspace Files panel", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
const workspaceFolders: string[] = [];
|
||||
const outsideFolders: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
client: null,
|
||||
openFile: () => {},
|
||||
openDirectory: (directory) => {
|
||||
workspaceFolders.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
outsideFolders.push(directory);
|
||||
},
|
||||
});
|
||||
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
|
||||
await opener.open({ raw, path: raw }, "preferred");
|
||||
}
|
||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||
expect(workspaceFolders).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
@@ -14,12 +14,23 @@ interface PathOpenInput {
|
||||
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
|
||||
}
|
||||
|
||||
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
|
||||
type PathOpenTarget =
|
||||
| { kind: "file" }
|
||||
| { kind: "directory"; directoryPath: string }
|
||||
| { kind: "outsideDirectory"; directoryPath: string };
|
||||
|
||||
// Workspace paths arrive relative to the root; anything still absolute or
|
||||
// home-relative lives outside it, where the Files panel cannot list it.
|
||||
function isOutsideWorkspacePath(pathValue: string): boolean {
|
||||
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
|
||||
}
|
||||
|
||||
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
|
||||
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
|
||||
if (!input.file) {
|
||||
return { kind: "directory", directoryPath: input.directory };
|
||||
return isOutsideWorkspacePath(input.directory)
|
||||
? { kind: "outsideDirectory", directoryPath: input.directory }
|
||||
: { kind: "directory", directoryPath: input.directory };
|
||||
}
|
||||
if (
|
||||
input.lineStart !== undefined ||
|
||||
@@ -42,6 +53,7 @@ export function createInlinePathOpener(input: {
|
||||
client: PathOpenInput["client"];
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
openOutsideDirectory: (path: string) => void;
|
||||
}) {
|
||||
let generation = 0;
|
||||
return {
|
||||
@@ -58,6 +70,8 @@ export function createInlinePathOpener(input: {
|
||||
if (ownGeneration !== generation) return;
|
||||
if (destination.kind === "directory") {
|
||||
input.openDirectory(destination.directoryPath);
|
||||
} else if (destination.kind === "outsideDirectory") {
|
||||
input.openOutsideDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
|
||||
@@ -1692,7 +1692,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 0,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ export function MessagePreview({ children }: { children: ReactNode }) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
style={styles.toggle}
|
||||
style={[styles.toggle, expanded && styles.expandedToggle]}
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={toggle}
|
||||
>
|
||||
@@ -73,6 +73,7 @@ export function MessageSender({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={renderProviderIcon}
|
||||
style={styles.sender}
|
||||
disabled={!host}
|
||||
onPress={openSender}
|
||||
>
|
||||
@@ -84,6 +85,8 @@ export function MessageSender({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" },
|
||||
body: { flexShrink: 0 },
|
||||
sender: { paddingRight: 0, borderRightWidth: 0 },
|
||||
toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 },
|
||||
expandedToggle: { marginTop: theme.spacing[2] },
|
||||
fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] },
|
||||
}));
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
@@ -390,7 +390,7 @@ export function WorkspaceDraftAgentTab({
|
||||
const editingSession = useComposerEditingSession("chat");
|
||||
const isComposerFullscreen = useComposerFullscreen(editingSession);
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
COMPACT_CONTENT_WIDTH,
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
);
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
|
||||
@@ -11,10 +11,11 @@ export const HEADER_INNER_HEIGHT_MOBILE = 56;
|
||||
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
|
||||
export const HEADER_TOP_PADDING_MOBILE = 8;
|
||||
|
||||
export const COMPACT_FORM_FACTOR_WIDTH = 500;
|
||||
// Measured composer width; independent of the window's compact/wide breakpoint.
|
||||
export const COMPACT_CONTENT_WIDTH = 500;
|
||||
|
||||
// Settings uses the canonical desktop list + detail layout. Its sidebar and
|
||||
// detail target must fit together before it can share width with app navigation.
|
||||
// Settings navigation can shrink below its preferred width to preserve the detail
|
||||
// minimum. Both targets must fit before Settings shares width with app navigation.
|
||||
export const SETTINGS_DESKTOP_SIDEBAR_WIDTH = 320;
|
||||
export const SETTINGS_DESKTOP_DETAIL_MIN_WIDTH = 400;
|
||||
export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH =
|
||||
@@ -33,7 +34,7 @@ export {
|
||||
getIsElectronMac as getIsElectronRuntimeMac,
|
||||
} from "./platform";
|
||||
|
||||
/** Below 600 points, sidebars overlay the workspace. Medium and wider layouts dock them. */
|
||||
/** Below md (600 points), sidebars overlay the workspace; wider layouts can dock them. */
|
||||
export function isCompactBreakpoint(breakpoint: string | undefined): boolean {
|
||||
return breakpoint === "xs" || breakpoint === "sm";
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export const ar: TranslationResources = {
|
||||
daemonClientUnavailable: "عميل Daemon غير متوفر",
|
||||
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
|
||||
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
|
||||
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
|
||||
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -63,6 +63,7 @@ export const en = {
|
||||
daemonClientUnavailable: "Daemon client unavailable",
|
||||
daemonClientDisconnected: "Daemon client is disconnected",
|
||||
noFileFound: "No file found for {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} is outside this workspace",
|
||||
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const es: TranslationResources = {
|
||||
daemonClientUnavailable: "ClienteDaemonno disponible",
|
||||
daemonClientDisconnected: "El clienteDaemonestá desconectado",
|
||||
noFileFound: "No se encontró ningún archivo para{{token}}",
|
||||
folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo",
|
||||
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const fr: TranslationResources = {
|
||||
daemonClientUnavailable: "Client du daemon indisponible",
|
||||
daemonClientDisconnected: "Le client du daemon est déconnecté",
|
||||
noFileFound: "Aucun fichier trouvé pour {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail",
|
||||
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const ja: TranslationResources = {
|
||||
daemonClientUnavailable: "デーモンクライアントが利用できません",
|
||||
daemonClientDisconnected: "デーモンクライアントが切断されています",
|
||||
noFileFound: "{{token}}のファイルが見つかりません",
|
||||
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
|
||||
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -67,6 +67,7 @@ export const ko: TranslationResources = {
|
||||
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
|
||||
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
|
||||
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
|
||||
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
|
||||
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const ptBR: TranslationResources = {
|
||||
daemonClientUnavailable: "Cliente do daemon indisponível",
|
||||
daemonClientDisconnected: "O cliente do daemon está desconectado",
|
||||
noFileFound: "Nenhum arquivo encontrado para {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} está fora deste workspace",
|
||||
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const ru: TranslationResources = {
|
||||
daemonClientUnavailable: "Daemon клиента недоступен",
|
||||
daemonClientDisconnected: "Daemon клиента отключен",
|
||||
noFileFound: "Файл для {{token}} не найден",
|
||||
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
|
||||
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -67,6 +67,7 @@ export const zhCN: TranslationResources = {
|
||||
daemonClientUnavailable: "Daemon client 不可用",
|
||||
daemonClientDisconnected: "Daemon client 已断开连接",
|
||||
noFileFound: "未找到 {{token}} 对应的文件",
|
||||
folderOutsideWorkspace: "{{path}} 不在此工作区内",
|
||||
unexpectedDictationError: "处理听写时发生意外错误。",
|
||||
},
|
||||
connectionStatus: {
|
||||
|
||||
@@ -44,7 +44,7 @@ import { useAgentDescriptor } from "@/agent-presentation";
|
||||
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
|
||||
@@ -1512,7 +1512,7 @@ function ActiveAgentComposer({
|
||||
}) {
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
COMPACT_CONTENT_WIDTH,
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
);
|
||||
const paneContext = usePaneContext();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as FileSystem from "expo-file-system/legacy";
|
||||
import { CryptoDigestAlgorithm, digestStringAsync, randomUUID } from "expo-crypto";
|
||||
import { createPluginClientBundleStore } from "./store";
|
||||
|
||||
export function createHostPluginClientBundleStore(serverId: string) {
|
||||
const directory = `${FileSystem.cacheDirectory}plugin-client-bundles/${encodeURIComponent(serverId)}/`;
|
||||
const ensureDirectory = async () => {
|
||||
if (!FileSystem.cacheDirectory) throw new Error("Plugin bundle cache directory unavailable");
|
||||
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
|
||||
};
|
||||
return createPluginClientBundleStore(
|
||||
{
|
||||
async list() {
|
||||
await ensureDirectory();
|
||||
return FileSystem.readDirectoryAsync(directory);
|
||||
},
|
||||
read: (name) => FileSystem.readAsStringAsync(`${directory}${name}`),
|
||||
async writeAtomic(name, contents) {
|
||||
await ensureDirectory();
|
||||
const temporary = `${directory}${randomUUID()}.tmp`;
|
||||
try {
|
||||
await FileSystem.writeAsStringAsync(temporary, contents);
|
||||
await FileSystem.moveAsync({ from: temporary, to: `${directory}${name}` });
|
||||
} finally {
|
||||
await FileSystem.deleteAsync(temporary, { idempotent: true });
|
||||
}
|
||||
},
|
||||
remove: (name) => FileSystem.deleteAsync(`${directory}${name}`, { idempotent: true }),
|
||||
},
|
||||
(contents) => digestStringAsync(CryptoDigestAlgorithm.SHA256, contents),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createPluginClientBundleStore } from "./store";
|
||||
|
||||
const DATABASE_NAME = "paseo-plugin-client-bundles";
|
||||
const STORE_NAME = "bundles";
|
||||
let opening: Promise<IDBDatabase> | undefined;
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
opening ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, 1);
|
||||
request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME));
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
}).catch((error) => {
|
||||
opening = undefined;
|
||||
throw error;
|
||||
});
|
||||
return opening;
|
||||
}
|
||||
|
||||
export function createHostPluginClientBundleStore(serverId: string) {
|
||||
const prefix = `${encodeURIComponent(serverId)}/`;
|
||||
const range = () => IDBKeyRange.bound(prefix, `${prefix}\uffff`);
|
||||
async function run<T>(
|
||||
mode: IDBTransactionMode,
|
||||
operation: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
const database = await openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE_NAME, mode);
|
||||
const request = operation(transaction.objectStore(STORE_NAME));
|
||||
transaction.addEventListener("complete", () => resolve(request.result));
|
||||
transaction.addEventListener("abort", () => reject(transaction.error));
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
});
|
||||
}
|
||||
return createPluginClientBundleStore(
|
||||
{
|
||||
async list() {
|
||||
return (await run("readonly", (store) => store.getAllKeys(range())))
|
||||
.filter((key): key is string => typeof key === "string")
|
||||
.map((key) => key.slice(prefix.length));
|
||||
},
|
||||
async read(name) {
|
||||
const result: unknown = await run("readonly", (store) => store.get(`${prefix}${name}`));
|
||||
if (typeof result !== "string") throw new Error("Missing plugin client bundle");
|
||||
return result;
|
||||
},
|
||||
async writeAtomic(name, contents) {
|
||||
await run("readwrite", (store) => store.put(contents, `${prefix}${name}`));
|
||||
},
|
||||
async remove(name) {
|
||||
await run("readwrite", (store) => store.delete(`${prefix}${name}`));
|
||||
},
|
||||
},
|
||||
async (contents) => {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createHostPluginClientBundleStore } from "./index.web";
|
||||
|
||||
async function sha256(contents: string) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
test("browser bundles survive a fresh store and remain isolated by host", async () => {
|
||||
const first = createHostPluginClientBundleStore("bundle-test-host-a");
|
||||
const second = createHostPluginClientBundleStore("bundle-test-host-b");
|
||||
const clientBundle = "bundle".repeat(250_000);
|
||||
const entry = {
|
||||
id: "same-plugin",
|
||||
clientBundle,
|
||||
clientBundleHash: await sha256(clientBundle),
|
||||
};
|
||||
const otherCode = "other host code";
|
||||
const other = {
|
||||
id: entry.id,
|
||||
clientBundle: otherCode,
|
||||
clientBundleHash: await sha256(otherCode),
|
||||
};
|
||||
try {
|
||||
await first.replace([entry]);
|
||||
await second.replace([other]);
|
||||
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([entry]);
|
||||
expect(await createHostPluginClientBundleStore("bundle-test-host-b").load()).toEqual([other]);
|
||||
await first.replace([]);
|
||||
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([]);
|
||||
expect(await second.load()).toEqual([other]);
|
||||
} finally {
|
||||
await first.replace([]);
|
||||
await second.replace([]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { expect, test } from "vitest";
|
||||
import { createPluginClientBundleStore, type PluginBundleFiles } from "./store";
|
||||
|
||||
const sha256 = async (contents: string) => createHash("sha256").update(contents).digest("hex");
|
||||
|
||||
class BundleFiles implements PluginBundleFiles {
|
||||
readonly contents = new Map<string, string>();
|
||||
readonly writes: string[] = [];
|
||||
async list() {
|
||||
return [...this.contents.keys()];
|
||||
}
|
||||
async read(name: string) {
|
||||
const contents = this.contents.get(name);
|
||||
if (contents === undefined) throw new Error("File disappeared");
|
||||
return contents;
|
||||
}
|
||||
async writeAtomic(name: string, contents: string) {
|
||||
this.writes.push(name);
|
||||
this.contents.set(name, contents);
|
||||
}
|
||||
async remove(name: string) {
|
||||
this.contents.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
async function bundle(id: string, clientBundle: string) {
|
||||
return { id, clientBundle, clientBundleHash: await sha256(clientBundle) };
|
||||
}
|
||||
|
||||
test("a cold store loads verified bundles and prunes versions missing from the latest catalog", async () => {
|
||||
const files = new BundleFiles();
|
||||
const old = await bundle("changed", "old");
|
||||
const kept = await bundle("kept", "Unicode: 折叠屏 🐚");
|
||||
await createPluginClientBundleStore(files, sha256).replace([old, kept]);
|
||||
const cold = createPluginClientBundleStore(files, sha256);
|
||||
expect(await cold.load()).toEqual([old, kept]);
|
||||
const changed = await bundle("changed", "new");
|
||||
await cold.replace([kept, changed]);
|
||||
expect(files.contents.size).toBe(2);
|
||||
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([kept, changed]);
|
||||
expect(files.writes).toHaveLength(3);
|
||||
await cold.replace([]);
|
||||
expect(files.contents.size).toBe(0);
|
||||
});
|
||||
|
||||
test("same-length corruption, truncated files and unreadable files are not advertised", async () => {
|
||||
const files = new BundleFiles();
|
||||
const entries = await Promise.all([
|
||||
bundle("same-length", "good"),
|
||||
bundle("truncated", "long"),
|
||||
bundle("missing", "lost"),
|
||||
]);
|
||||
await createPluginClientBundleStore(files, sha256).replace(entries);
|
||||
const names = [...files.contents.keys()];
|
||||
files.contents.set(names[0]!, "evil");
|
||||
files.contents.set(names[1]!, "lo");
|
||||
// The directory entry still exists while reading the file fails.
|
||||
const unreadable: PluginBundleFiles = {
|
||||
list: () => files.list(),
|
||||
read: (name) =>
|
||||
name === names[2] ? Promise.reject(new Error("Unreadable")) : files.read(name),
|
||||
writeAtomic: (name, contents) => files.writeAtomic(name, contents),
|
||||
remove: (name) => files.remove(name),
|
||||
};
|
||||
const cold = createPluginClientBundleStore(unreadable, sha256);
|
||||
expect(await cold.load()).toEqual([]);
|
||||
await cold.replace(entries);
|
||||
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual(entries);
|
||||
});
|
||||
|
||||
test("an interrupted atomic write leaves the prior catalog available for a cold reader", async () => {
|
||||
const files = new BundleFiles();
|
||||
const previous = await bundle("plugin", "previous");
|
||||
await createPluginClientBundleStore(files, sha256).replace([previous]);
|
||||
const failing: PluginBundleFiles = {
|
||||
list: () => files.list(),
|
||||
read: (name) => files.read(name),
|
||||
remove: (name) => files.remove(name),
|
||||
writeAtomic: async () => {
|
||||
throw new Error("Disk full");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
createPluginClientBundleStore(failing, sha256).replace([await bundle("plugin", "next")]),
|
||||
).rejects.toThrow("Disk full");
|
||||
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([previous]);
|
||||
});
|
||||
|
||||
// Exercise the app store through a fresh platform-agnostic client, using its transport port.
|
||||
test.each(["valid", "corrupt", "missing"])(
|
||||
"cold catalog uses a %s persisted bundle correctly",
|
||||
async (state) => {
|
||||
const files = new BundleFiles();
|
||||
const entry = await bundle("plugin", "good code");
|
||||
await createPluginClientBundleStore(files, sha256).replace([entry]);
|
||||
if (state === "corrupt") files.contents.set([...files.contents.keys()][0]!, "evil code");
|
||||
if (state === "missing") files.contents.clear();
|
||||
let open = () => {};
|
||||
let receive: (data: unknown, binary: boolean) => void = () => {};
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "plugin-cache-test",
|
||||
reconnect: { enabled: false },
|
||||
createPluginClientBundleStore: () => createPluginClientBundleStore(files, sha256),
|
||||
transportFactory: () => ({
|
||||
send(data) {
|
||||
if (typeof data !== "string") return;
|
||||
const frame = JSON.parse(data);
|
||||
if (frame.type !== "session") return;
|
||||
const request = frame.message;
|
||||
if (request.type !== "plugin.catalog.get.request") return;
|
||||
requests.push(request);
|
||||
const { clientBundle: _code, ...reference } = entry;
|
||||
receive(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
plugins: [request.cachedClientBundles ? reference : entry],
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
},
|
||||
close() {},
|
||||
onOpen(handler) {
|
||||
open = handler;
|
||||
return () => {};
|
||||
},
|
||||
onMessage(handler) {
|
||||
receive = handler;
|
||||
return () => {};
|
||||
},
|
||||
onClose() {
|
||||
return () => {};
|
||||
},
|
||||
onError() {
|
||||
return () => {};
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const connecting = client.connect();
|
||||
open();
|
||||
receive(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "cache-host",
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { plugins: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
await connecting;
|
||||
expect(await client.getPluginCatalog()).toEqual([entry]);
|
||||
if (state === "valid")
|
||||
expect(requests[0]?.cachedClientBundles).toEqual([
|
||||
{ id: entry.id, clientBundleHash: entry.clientBundleHash },
|
||||
]);
|
||||
else expect(requests[0]).not.toHaveProperty("cachedClientBundles");
|
||||
await expect.poll(() => createPluginClientBundleStore(files, sha256).load()).toEqual([entry]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
PluginClientBundle,
|
||||
PluginClientBundleStore,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
/** Each adapter is scoped to one host. Writes publish a complete file/record atomically. */
|
||||
export interface PluginBundleFiles {
|
||||
list(): Promise<string[]>;
|
||||
read(name: string): Promise<string>;
|
||||
writeAtomic(name: string, contents: string): Promise<void>;
|
||||
remove(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function createPluginClientBundleStore(
|
||||
files: PluginBundleFiles,
|
||||
sha256: (contents: string) => Promise<string>,
|
||||
): PluginClientBundleStore {
|
||||
const verified = new Set<string>();
|
||||
const filename = (bundle: PluginClientBundle) =>
|
||||
`${encodeURIComponent(bundle.id)}.${bundle.clientBundleHash}.js`;
|
||||
return {
|
||||
async load() {
|
||||
const bundles: PluginClientBundle[] = [];
|
||||
for (const name of await files.list()) {
|
||||
try {
|
||||
const match = /^(.*)\.([a-f0-9]{64})\.js$/.exec(name);
|
||||
if (!match) continue;
|
||||
const id = decodeURIComponent(match[1]!);
|
||||
const clientBundleHash = match[2]!;
|
||||
const clientBundle = await files.read(name);
|
||||
if ((await sha256(clientBundle)) !== clientBundleHash) continue;
|
||||
verified.add(name);
|
||||
bundles.push({ id, clientBundleHash, clientBundle });
|
||||
} catch {
|
||||
// Missing, unreadable or corrupt files are cache misses, never executable code.
|
||||
}
|
||||
}
|
||||
return bundles;
|
||||
},
|
||||
async replace(bundles) {
|
||||
const retained = new Set<string>();
|
||||
for (const bundle of bundles) {
|
||||
const name = filename(bundle);
|
||||
retained.add(name);
|
||||
if (verified.has(name)) continue;
|
||||
await files.writeAtomic(name, bundle.clientBundle);
|
||||
verified.add(name);
|
||||
}
|
||||
for (const name of await files.list()) {
|
||||
if (retained.has(name)) continue;
|
||||
await files.remove(name);
|
||||
verified.delete(name);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
|
||||
import { watchdog } from "@/watchdog";
|
||||
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
@@ -589,6 +590,7 @@ function createDefaultDeps(
|
||||
capabilities: appCapabilities,
|
||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||
providerSnapshots: "wire",
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
...(host.password ? { password: host.password } : {}),
|
||||
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||
} satisfies Omit<DaemonClientConfig, "url">;
|
||||
@@ -668,6 +670,7 @@ function createDefaultDeps(
|
||||
),
|
||||
reconnect: { enabled: false },
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
@@ -684,6 +687,7 @@ function createDefaultDeps(
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
capabilities: appCapabilities,
|
||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
});
|
||||
},
|
||||
getClientId: () => getOrCreateClientId(),
|
||||
|
||||
@@ -122,7 +122,11 @@ import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-p
|
||||
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
SETTINGS_DESKTOP_DETAIL_MIN_WIDTH,
|
||||
SETTINGS_DESKTOP_SIDEBAR_WIDTH,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { densityIconBox, densityIconSize } from "@/styles/density";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import {
|
||||
@@ -1544,12 +1548,14 @@ const desktopStyles = StyleSheet.create({
|
||||
},
|
||||
contentPane: {
|
||||
flex: 1,
|
||||
minWidth: SETTINGS_DESKTOP_DETAIL_MIN_WIDTH,
|
||||
},
|
||||
});
|
||||
|
||||
const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
desktopContainer: {
|
||||
width: SETTINGS_DESKTOP_SIDEBAR_WIDTH,
|
||||
flexShrink: 1,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
|
||||
@@ -64,6 +64,7 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
@@ -3682,6 +3683,7 @@ function WorkspaceScreenContent({
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId);
|
||||
const markdownPathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
@@ -3689,8 +3691,15 @@ function WorkspaceScreenContent({
|
||||
client,
|
||||
openFile: openMarkdownFile,
|
||||
openDirectory: openMarkdownDirectory,
|
||||
openOutsideDirectory: openMarkdownOutsideDirectory,
|
||||
}),
|
||||
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
|
||||
[
|
||||
workspaceDirectory,
|
||||
client,
|
||||
openMarkdownFile,
|
||||
openMarkdownDirectory,
|
||||
openMarkdownOutsideDirectory,
|
||||
],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused) markdownPathOpener.cancel();
|
||||
|
||||
@@ -6,8 +6,7 @@ StyleSheet.configure({
|
||||
breakpoints: {
|
||||
xs: 0,
|
||||
sm: 576,
|
||||
medium: 600,
|
||||
md: 720,
|
||||
md: 600,
|
||||
lg: 992,
|
||||
xl: 1200,
|
||||
},
|
||||
@@ -22,7 +21,6 @@ type AppThemes = typeof REGISTERED_THEMES;
|
||||
interface AppBreakpoints {
|
||||
xs: number;
|
||||
sm: number;
|
||||
medium: number;
|
||||
md: number;
|
||||
lg: number;
|
||||
xl: number;
|
||||
|
||||
@@ -371,6 +371,22 @@ describe("viewed timeline persistence", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preparing an already painted cache again keeps history out of the live head", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const replica = createTimelineReplica({
|
||||
serverId: SERVER_ID,
|
||||
storage: { readTimeline: async () => cachedTimeline(), commitTimeline: () => undefined },
|
||||
prepareAgent: async () => undefined,
|
||||
});
|
||||
await replica.prepare(AGENT_ID);
|
||||
const painted = useSessionStore.getState().sessions[SERVER_ID]!.agentStreamTail.get(AGENT_ID);
|
||||
expect(painted).toEqual(cachedTimeline().items);
|
||||
await replica.prepare(AGENT_ID);
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID]!;
|
||||
expect(session.agentStreamHead.get(AGENT_ID) ?? []).toEqual([]);
|
||||
expect(session.agentStreamTail.get(AGENT_ID)).toBe(painted);
|
||||
});
|
||||
|
||||
it("reconciles a live head that overlaps the cached canonical timeline", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
let release!: (value: CachedTimeline) => void;
|
||||
|
||||
@@ -109,6 +109,15 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
async prepare(agentId: string): Promise<void> {
|
||||
const existing = this.preparations.get(agentId);
|
||||
if (existing) return existing;
|
||||
const timeline = selectAgentTimelineState(
|
||||
useSessionStore.getState().sessions[this.serverId],
|
||||
agentId,
|
||||
);
|
||||
// A route and the visible-chat owner can both prepare before catch-up finishes.
|
||||
// Repainting that same cache would treat its history as a live head.
|
||||
if (timeline.status !== "cold" && this.cachedRanges.has(agentId)) {
|
||||
return;
|
||||
}
|
||||
const preparation = this.load(agentId).finally(() => {
|
||||
if (this.preparations.get(agentId) === preparation) {
|
||||
this.preparations.delete(agentId);
|
||||
|
||||
@@ -44,7 +44,9 @@ export function useOverviewSummaryLabel(
|
||||
count === 1 ? action.toLocaleLowerCase() : `${action.toLocaleLowerCase()} ×${count}`,
|
||||
);
|
||||
}
|
||||
if (summary.thoughtCount > 0) parts.push(t("toolCallGroup.thought").toLocaleLowerCase());
|
||||
// Reasoning stays in the details; it is not an action in a work summary.
|
||||
// Keep a label for a group containing only reasoning.
|
||||
if (parts.length === 0 && summary.thoughtCount > 0) return t("toolCallGroup.thought");
|
||||
return joinSummaryParts(parts, t("toolCallGroup.and"));
|
||||
}, [leadingParts, summary, t]);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,23 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the bundle cache factory on a probe client that the host runtime can adopt", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const createPluginClientBundleStore = () => ({
|
||||
async load() {
|
||||
return [];
|
||||
},
|
||||
async replace() {},
|
||||
});
|
||||
const { client } = await connectToDaemon(
|
||||
{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" },
|
||||
{ createPluginClientBundleStore },
|
||||
probe.deps,
|
||||
);
|
||||
expect(client.config.createPluginClientBundleStore).toBe(createPluginClientBundleStore);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("encodes the local socket target into the client config", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
|
||||
@@ -132,12 +132,13 @@ function resolveConnectionCredentials(
|
||||
export async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string,
|
||||
options?: {
|
||||
options: {
|
||||
capabilities?: DaemonClientConfig["capabilities"];
|
||||
trace?: DaemonClientConfig["trace"];
|
||||
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
|
||||
password?: string;
|
||||
localCredential?: DaemonClientConfig["localCredential"];
|
||||
},
|
||||
} = {},
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
| "getClientId"
|
||||
@@ -157,8 +158,9 @@ export async function buildClientConfig(
|
||||
suppressSendErrors: true,
|
||||
reconnect: { enabled: false },
|
||||
...resolveConnectionCredentials(connection, options),
|
||||
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
|
||||
...(options?.trace ? { trace: options.trace } : {}),
|
||||
...(options.capabilities ? { capabilities: options.capabilities } : {}),
|
||||
...(options.trace ? { trace: options.trace } : {}),
|
||||
createPluginClientBundleStore: options.createPluginClientBundleStore,
|
||||
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
||||
desktopTransportFactory
|
||||
? { transportFactory: desktopTransportFactory }
|
||||
@@ -288,6 +290,7 @@ interface ProbeOptions {
|
||||
localCredential?: DaemonClientConfig["localCredential"];
|
||||
capabilities?: DaemonClientConfig["capabilities"];
|
||||
trace?: DaemonClientConfig["trace"];
|
||||
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
|
||||
}
|
||||
|
||||
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||
|
||||
/**
|
||||
* Opens a folder outside the workspace in the system file manager. The Files
|
||||
* panel only lists the workspace, so hosts without a local file manager get a
|
||||
* toast instead.
|
||||
*/
|
||||
export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isLocalExecution = useIsLocalDaemon(serverId);
|
||||
const { targets } = useDesktopOpenTargets({ isLocalExecution });
|
||||
const fileManagerTarget = targets.find((target) => target.kind === "file-manager");
|
||||
|
||||
return useCallback(
|
||||
(folderPath: string) => {
|
||||
const showOutsideWorkspace = () =>
|
||||
toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath }));
|
||||
if (!fileManagerTarget) {
|
||||
showOutsideWorkspace();
|
||||
return;
|
||||
}
|
||||
void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch(
|
||||
(error: unknown) => {
|
||||
console.warn("[open-outside-workspace-folder] open failed", error);
|
||||
showOutsideWorkspace();
|
||||
},
|
||||
);
|
||||
},
|
||||
[fileManagerTarget, t, toast],
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.13.2",
|
||||
"@getpaseo/protocol": "0.13.2",
|
||||
"@getpaseo/server": "0.13.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/server": "0.14.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -47,8 +47,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.13.2",
|
||||
"@getpaseo/relay": "0.13.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
DaemonClient,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type CreateAgentRequestOptions,
|
||||
type DaemonTransport,
|
||||
type Logger,
|
||||
type PluginClientBundle,
|
||||
type PluginClientBundleStore,
|
||||
} from "./daemon-client";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
@@ -2099,6 +2101,188 @@ test("gets a structured plugin log snapshot", async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
describe("plugin catalog", () => {
|
||||
async function connectedClient(createStore?: (serverId: string) => PluginClientBundleStore) {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger: createMockLogger(),
|
||||
createPluginClientBundleStore: createStore,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ features: { plugins: true } });
|
||||
await connectPromise;
|
||||
return { client, mock };
|
||||
}
|
||||
|
||||
async function fetchCatalog(
|
||||
harness: Awaited<ReturnType<typeof connectedClient>>,
|
||||
respond: (request: Record<string, unknown>) => Array<Record<string, unknown>>,
|
||||
) {
|
||||
const before = harness.mock.sent.length;
|
||||
const response = harness.client.getPluginCatalog();
|
||||
await vi.waitFor(() => expect(harness.mock.sent.length).toBeGreaterThan(before));
|
||||
const request = parseSentFrame(harness.mock.sent.at(-1));
|
||||
harness.mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: { requestId: request.requestId, plugins: respond(request) },
|
||||
}),
|
||||
);
|
||||
return { request, plugins: await response };
|
||||
}
|
||||
|
||||
test("reuses retained client bundles the daemon leaves out", async () => {
|
||||
const harness = await connectedClient();
|
||||
const first = await fetchCatalog(harness, () => [
|
||||
{ id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundle: "old bundle", clientBundleHash: "hash-old" },
|
||||
]);
|
||||
expect(first.request).not.toHaveProperty("cachedClientBundles");
|
||||
|
||||
const second = await fetchCatalog(harness, () => [
|
||||
{ id: "kept", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
]);
|
||||
|
||||
expect(second.request.cachedClientBundles).toEqual([
|
||||
{ id: "kept", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundleHash: "hash-old" },
|
||||
]);
|
||||
expect(second.plugins).toEqual([
|
||||
{ id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
]);
|
||||
const third = await fetchCatalog(harness, () => []);
|
||||
expect(third.request.cachedClientBundles).toEqual([
|
||||
{ id: "kept", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundleHash: "hash-new" },
|
||||
]);
|
||||
const fourth = await fetchCatalog(harness, () => []);
|
||||
expect(fourth.request).not.toHaveProperty("cachedClientBundles");
|
||||
});
|
||||
|
||||
test("keeps fetching every bundle from a daemon that sends no bundle hashes", async () => {
|
||||
const harness = await connectedClient();
|
||||
const legacyCatalog = () => [{ id: "legacy", clientBundle: "legacy bundle" }];
|
||||
await fetchCatalog(harness, legacyCatalog);
|
||||
|
||||
const second = await fetchCatalog(harness, legacyCatalog);
|
||||
|
||||
expect(second.request).not.toHaveProperty("cachedClientBundles");
|
||||
expect(second.plugins).toEqual([{ id: "legacy", clientBundle: "legacy bundle" }]);
|
||||
});
|
||||
|
||||
test("a fresh client advertises persisted bundles on its first fetch and expands omitted code", async () => {
|
||||
let persisted: PluginClientBundle[] = [];
|
||||
let loads = 0;
|
||||
const store: PluginClientBundleStore = {
|
||||
async load() {
|
||||
loads++;
|
||||
return persisted;
|
||||
},
|
||||
async replace(bundles) {
|
||||
persisted = bundles;
|
||||
},
|
||||
};
|
||||
const createdFor: string[] = [];
|
||||
const createStore = (serverId: string) => {
|
||||
createdFor.push(serverId);
|
||||
return store;
|
||||
};
|
||||
const bundle = { id: "kept", clientBundleHash: "hash-kept", clientBundle: "stored code" };
|
||||
const warm = await connectedClient(createStore);
|
||||
await fetchCatalog(warm, () => [bundle]);
|
||||
const cold = await connectedClient(createStore);
|
||||
const first = await fetchCatalog(cold, () => [{ id: "kept", clientBundleHash: "hash-kept" }]);
|
||||
expect(first.request.cachedClientBundles).toEqual([
|
||||
{ id: "kept", clientBundleHash: "hash-kept" },
|
||||
]);
|
||||
expect(first.plugins).toEqual([bundle]);
|
||||
expect(loads).toBe(2);
|
||||
await fetchCatalog(cold, () => []);
|
||||
expect(loads).toBe(2);
|
||||
expect(createdFor).toEqual([
|
||||
warm.client.getLastServerInfoMessage()!.serverId,
|
||||
cold.client.getLastServerInfoMessage()!.serverId,
|
||||
]);
|
||||
expect(persisted).toEqual([]);
|
||||
});
|
||||
|
||||
test.each(["missing", "unreadable"])(
|
||||
"a %s persistent cache requests full bundles",
|
||||
async (state) => {
|
||||
const store: PluginClientBundleStore = {
|
||||
async load() {
|
||||
if (state === "unreadable") throw new Error("Storage unavailable");
|
||||
return [];
|
||||
},
|
||||
async replace() {},
|
||||
};
|
||||
const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" };
|
||||
const result = await fetchCatalog(await connectedClient(() => store), () => [bundle]);
|
||||
expect(result.request).not.toHaveProperty("cachedClientBundles");
|
||||
expect(result.plugins).toEqual([bundle]);
|
||||
},
|
||||
);
|
||||
|
||||
test("failed cache writes never fail plugin loading", async () => {
|
||||
const store: PluginClientBundleStore = {
|
||||
async load() {
|
||||
return [];
|
||||
},
|
||||
async replace() {
|
||||
throw new Error("Disk full");
|
||||
},
|
||||
};
|
||||
const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" };
|
||||
expect(
|
||||
(await fetchCatalog(await connectedClient(() => store), () => [bundle])).plugins,
|
||||
).toEqual([bundle]);
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"usable catalogs do not wait for storage; latest write survives a failed first write: %s",
|
||||
async (failFirstWrite) => {
|
||||
const writes: PluginClientBundle[][] = [];
|
||||
let finishWrite = () => {};
|
||||
const blockedWrite = new Promise<void>((resolve) => {
|
||||
finishWrite = resolve;
|
||||
});
|
||||
const store: PluginClientBundleStore = {
|
||||
async load() {
|
||||
return [];
|
||||
},
|
||||
async replace(bundles) {
|
||||
writes.push(bundles);
|
||||
if (writes.length === 1) {
|
||||
await blockedWrite;
|
||||
if (failFirstWrite) throw new Error("Disk full");
|
||||
}
|
||||
},
|
||||
};
|
||||
const harness = await connectedClient(() => store);
|
||||
const first = { id: "plugin", clientBundle: "old", clientBundleHash: "hash-old" };
|
||||
const changed = { id: "plugin", clientBundle: "new", clientBundleHash: "hash-new" };
|
||||
try {
|
||||
expect((await fetchCatalog(harness, () => [first])).plugins).toEqual([first]);
|
||||
await vi.waitFor(() => expect(writes).toEqual([[first]]));
|
||||
expect((await fetchCatalog(harness, () => [changed])).plugins).toEqual([changed]);
|
||||
expect((await fetchCatalog(harness, () => [])).plugins).toEqual([]);
|
||||
expect(writes).toEqual([[first]]);
|
||||
finishWrite();
|
||||
await vi.waitFor(() => expect(writes).toEqual([[first], []]));
|
||||
} finally {
|
||||
finishWrite();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a plugin reload pending past the session RPC deadline", async () => {
|
||||
useHeartbeatClock();
|
||||
try {
|
||||
|
||||
@@ -396,6 +396,17 @@ export type DaemonEventHandler = (event: DaemonEvent) => void;
|
||||
export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteRequest;
|
||||
export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse;
|
||||
|
||||
export interface PluginClientBundle {
|
||||
id: string;
|
||||
clientBundleHash: string;
|
||||
clientBundle: string;
|
||||
}
|
||||
|
||||
/** Host-scoped storage. load must return only bundles whose contents have been verified. */
|
||||
export interface PluginClientBundleStore {
|
||||
load(): Promise<PluginClientBundle[]>;
|
||||
replace(bundles: PluginClientBundle[]): Promise<void>;
|
||||
}
|
||||
/** Endpoint security negotiated on a relay connection. See docs/relay-endpoint-security.md. */
|
||||
export type RelaySecurityMode = "legacy" | "protected";
|
||||
|
||||
@@ -406,6 +417,8 @@ export interface DaemonClientConfig {
|
||||
sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[];
|
||||
url: string;
|
||||
clientId: string;
|
||||
/** Create one cache for the confirmed daemon identity, retained for this client lifetime. */
|
||||
createPluginClientBundleStore?: (serverId: string) => PluginClientBundleStore;
|
||||
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
|
||||
appVersion?: string;
|
||||
runtimeGeneration?: number | null;
|
||||
@@ -1016,6 +1029,12 @@ type CorrelatedResponsePayloads = {
|
||||
type CorrelatedResponsePayload<TType extends CorrelatedResponseType> =
|
||||
CorrelatedResponsePayloads[TType];
|
||||
|
||||
type PluginCatalogResponseEntry =
|
||||
CorrelatedResponsePayload<"plugin.catalog.get.response">["plugins"][number];
|
||||
export type PluginCatalogEntry = Omit<PluginCatalogResponseEntry, "clientBundle"> & {
|
||||
clientBundle: string;
|
||||
};
|
||||
|
||||
export class DaemonConnectionError extends Error {
|
||||
readonly serverId?: string;
|
||||
readonly outcome?: "unknown";
|
||||
@@ -1343,6 +1362,15 @@ export class DaemonClient {
|
||||
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
|
||||
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
|
||||
private completedBinaryFileReads = new Map<string, FileReadResult>();
|
||||
/** The last catalog's client bundles by plugin id, so later catalogs can leave them out. */
|
||||
private pluginClientBundles = new Map<
|
||||
string,
|
||||
{ clientBundleHash: string; clientBundle: string }
|
||||
>();
|
||||
private pluginClientBundleStore: PluginClientBundleStore | undefined;
|
||||
private pluginClientBundlesLoaded: Promise<void> | undefined;
|
||||
private pluginClientBundlesWrite: PluginClientBundle[] | undefined;
|
||||
private pluginClientBundlesWriting = false;
|
||||
private logger: Logger;
|
||||
private pendingSendQueue: PendingSend[] = [];
|
||||
/**
|
||||
@@ -5793,14 +5821,82 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getPluginCatalog() {
|
||||
async getPluginCatalog(): Promise<PluginCatalogEntry[]> {
|
||||
if (this.config.createPluginClientBundleStore && !this.pluginClientBundleStore) {
|
||||
const serverInfo = this.getLastServerInfoMessage();
|
||||
if (!serverInfo) throw new Error("Plugin catalog requires a connected host");
|
||||
this.pluginClientBundleStore = this.config.createPluginClientBundleStore(serverInfo.serverId);
|
||||
}
|
||||
const store = this.pluginClientBundleStore;
|
||||
if (store) {
|
||||
this.pluginClientBundlesLoaded ??= store.load().then(
|
||||
(bundles) => {
|
||||
for (const { id, ...bundle } of bundles) this.pluginClientBundles.set(id, bundle);
|
||||
return undefined;
|
||||
},
|
||||
(error) => this.logger.warn({ err: error }, "Failed to load plugin client bundles"),
|
||||
);
|
||||
await this.pluginClientBundlesLoaded;
|
||||
}
|
||||
const requestId = this.createRequestId();
|
||||
const cached = this.pluginClientBundles;
|
||||
const cachedClientBundles = [...cached].map(([id, { clientBundleHash }]) => ({
|
||||
id,
|
||||
clientBundleHash,
|
||||
}));
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: { type: "plugin.catalog.get.request", requestId },
|
||||
message: {
|
||||
type: "plugin.catalog.get.request",
|
||||
requestId,
|
||||
...(cachedClientBundles.length > 0 ? { cachedClientBundles } : {}),
|
||||
},
|
||||
responseType: "plugin.catalog.get.response",
|
||||
});
|
||||
return payload.plugins;
|
||||
const retained = new Map<string, { clientBundleHash: string; clientBundle: string }>();
|
||||
const plugins = payload.plugins.map(({ clientBundle: sent, ...plugin }): PluginCatalogEntry => {
|
||||
const previous = cached.get(plugin.id);
|
||||
const clientBundle =
|
||||
sent ??
|
||||
(previous?.clientBundleHash === plugin.clientBundleHash
|
||||
? previous?.clientBundle
|
||||
: undefined);
|
||||
if (clientBundle === undefined) {
|
||||
throw new Error(`Plugin catalog left out the client bundle for ${plugin.id}`);
|
||||
}
|
||||
// COMPAT(pluginCatalogBundleHash): introduced by PR #6732 (unreleased); remove after
|
||||
// 2027-04-27 once the daemon floor includes bundle hashes. Older bundles are not retained.
|
||||
if (plugin.clientBundleHash !== undefined) {
|
||||
retained.set(plugin.id, { clientBundleHash: plugin.clientBundleHash, clientBundle });
|
||||
}
|
||||
return Object.assign(plugin, { clientBundle });
|
||||
});
|
||||
this.pluginClientBundles = retained;
|
||||
if (store) {
|
||||
this.pluginClientBundlesWrite = [...retained].map(([id, bundle]) =>
|
||||
Object.assign({ id }, bundle),
|
||||
);
|
||||
void this.persistPluginClientBundles(store);
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
private async persistPluginClientBundles(store: PluginClientBundleStore): Promise<void> {
|
||||
if (this.pluginClientBundlesWriting) return;
|
||||
this.pluginClientBundlesWriting = true;
|
||||
try {
|
||||
while (this.pluginClientBundlesWrite) {
|
||||
const bundles = this.pluginClientBundlesWrite;
|
||||
this.pluginClientBundlesWrite = undefined;
|
||||
try {
|
||||
await store.replace(bundles);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to persist plugin client bundles");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.pluginClientBundlesWriting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async listPlugins(): Promise<PluginListItem[]> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -86,6 +86,10 @@ class FakeEditorTargets implements EditorTargetRuntime {
|
||||
return this.paths.has(targetPath);
|
||||
}
|
||||
|
||||
expandHomePath(targetPath: string): string {
|
||||
return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath;
|
||||
}
|
||||
|
||||
isAbsolutePath(targetPath: string): boolean {
|
||||
return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath);
|
||||
}
|
||||
@@ -513,6 +517,19 @@ describe("editor target registry", () => {
|
||||
expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]);
|
||||
});
|
||||
|
||||
it("opens a home-relative folder in the file manager", async () => {
|
||||
const runtime = new FakeEditorTargets();
|
||||
runtime.addPath("/home/me/Downloads/contracts");
|
||||
|
||||
await openEditorTarget(
|
||||
{ editorId: "file-manager", workspacePath: "~/Downloads/contracts" },
|
||||
runtime,
|
||||
[fileManagerTarget],
|
||||
);
|
||||
|
||||
expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]);
|
||||
});
|
||||
|
||||
it("keeps the platform file-manager ids used by stored preferences", async () => {
|
||||
const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [
|
||||
finderTarget,
|
||||
|
||||
@@ -97,10 +97,16 @@ export function getEditorTarget(
|
||||
}
|
||||
|
||||
export async function openEditorTarget(
|
||||
input: EditorTargetLaunchInput & { editorId: string },
|
||||
request: EditorTargetLaunchInput & { editorId: string },
|
||||
runtime: EditorTargetRuntime,
|
||||
targets: readonly EditorTarget[] = EDITOR_TARGETS,
|
||||
): Promise<void> {
|
||||
// Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard.
|
||||
const input = {
|
||||
...request,
|
||||
workspacePath: runtime.expandHomePath(request.workspacePath),
|
||||
filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath),
|
||||
};
|
||||
if (!runtime.isAbsolutePath(input.workspacePath)) {
|
||||
throw new Error("Editor target workspace path must be an absolute local path");
|
||||
}
|
||||
|
||||
@@ -61,6 +61,14 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
|
||||
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
|
||||
}
|
||||
|
||||
function expandHomePath(value: string, homeDirectory: string): string {
|
||||
if (value === "~") return homeDirectory;
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
||||
return path.join(homeDirectory, value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveExecutable(
|
||||
commands: readonly string[],
|
||||
input: {
|
||||
@@ -166,6 +174,7 @@ export function createEditorTargetRuntime(
|
||||
pathExists,
|
||||
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
|
||||
listDirectory,
|
||||
expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory),
|
||||
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
|
||||
async spawnDetached({ command, args }) {
|
||||
const commandScript = isWindowsCommandScript(command, platform);
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface EditorTargetRuntime {
|
||||
pathExists(path: string): boolean;
|
||||
isAbsolutePath(path: string): boolean;
|
||||
listDirectory(path: string): EditorTargetDirectoryEntry[];
|
||||
/** Resolves a leading `~` to the user's home directory. */
|
||||
expandHomePath(path: string): string;
|
||||
resolveCommand(commands: readonly string[]): string | null;
|
||||
spawnDetached(input: { command: string; args: readonly string[] }): Promise<void>;
|
||||
openPath(path: string): Promise<void>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -72,8 +72,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.13.2",
|
||||
"@getpaseo/protocol": "0.13.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -83,8 +83,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.13.2",
|
||||
"@getpaseo/protocol": "0.13.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -997,7 +997,9 @@ class AcpRuntime {
|
||||
): string {
|
||||
if (messageId) {
|
||||
this.fallbackChunkIds.delete(kind);
|
||||
return messageId;
|
||||
// Native IDs identify a message within its chunk kind. Timeline IDs must
|
||||
// also distinguish reasoning from replies and from generated fallback IDs.
|
||||
return JSON.stringify([kind, messageId]);
|
||||
}
|
||||
let id = this.fallbackChunkIds.get(kind);
|
||||
if (!id) {
|
||||
|
||||
@@ -1307,7 +1307,15 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
|
||||
const items = events.flatMap((event) =>
|
||||
event.type === "timeline.item" &&
|
||||
(event.item.type === "assistant_message" || event.item.type === "reasoning")
|
||||
? [{ id: event.item.id, text: event.item.text }]
|
||||
? [
|
||||
{
|
||||
id: event.item.id,
|
||||
text: event.item.text,
|
||||
...(event.item.type === "assistant_message" && event.item.messageId
|
||||
? { messageId: event.item.messageId }
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
expect(items).toEqual([
|
||||
@@ -1315,7 +1323,11 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
|
||||
{ id: "agent_message_chunk:1", text: "- **Current temperature**: 25°C" },
|
||||
{ id: "agent_message_chunk:2", text: "next turn" },
|
||||
{ id: "agent_thought_chunk:3", text: "think A" },
|
||||
{ id: "m-explicit", text: "say" },
|
||||
{
|
||||
id: JSON.stringify(["agent_message_chunk", "m-explicit"]),
|
||||
messageId: "m-explicit",
|
||||
text: "say",
|
||||
},
|
||||
{ id: "agent_thought_chunk:4", text: "think B" },
|
||||
]);
|
||||
await connection.close();
|
||||
@@ -1402,6 +1414,21 @@ async function streamChunkTurns(
|
||||
}
|
||||
|
||||
describe("ACP streamed message boundaries", () => {
|
||||
it("keeps reasoning separate from a reply sharing its native message ID", async () => {
|
||||
expect(
|
||||
await streamChunkTurns([
|
||||
textChunk("private ", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("reasoning", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("public ", "agent_message_chunk", "shared-id"),
|
||||
textChunk("reply", "agent_message_chunk", "shared-id"),
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "reasoning", text: "private reasoning" },
|
||||
{ type: "assistant_message", text: "public reply" },
|
||||
{ type: "assistant_message", text: "Next turn" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "tool call",
|
||||
@@ -1584,8 +1611,12 @@ describe("ACP session history replay", () => {
|
||||
);
|
||||
try {
|
||||
expect(timelineProjection(events)).toEqual([
|
||||
{ type: "user_message", id: "user-1", text: "What is the " },
|
||||
{ type: "user_message", id: "user-1", text: "What is the temperature?" },
|
||||
{ type: "user_message", id: '["user_message_chunk","user-1"]', text: "What is the " },
|
||||
{
|
||||
type: "user_message",
|
||||
id: '["user_message_chunk","user-1"]',
|
||||
text: "What is the temperature?",
|
||||
},
|
||||
{ type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" },
|
||||
{ type: "tool_call", id: "tool-1", status: "running" },
|
||||
{ type: "tool_call", id: "tool-1", status: "completed" },
|
||||
@@ -1599,6 +1630,48 @@ describe("ACP session history replay", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps replayed user, reasoning and reply chunks separate when their native IDs match", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[
|
||||
userChunk("What is the ", "shared-id"),
|
||||
userChunk("temperature?", "shared-id"),
|
||||
textChunk("Checking ", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("the sensor", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("It is ", "agent_message_chunk", "shared-id"),
|
||||
textChunk("25°C", "agent_message_chunk", "shared-id"),
|
||||
],
|
||||
"replay",
|
||||
);
|
||||
try {
|
||||
const items = new Map(
|
||||
events.flatMap((event) =>
|
||||
event.type === "timeline.item" ? [[event.item.id, event.item] as const] : [],
|
||||
),
|
||||
);
|
||||
expect([...items.values()]).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
id: '["user_message_chunk","shared-id"]',
|
||||
messageId: "shared-id",
|
||||
text: "What is the temperature?",
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
id: '["agent_thought_chunk","shared-id"]',
|
||||
text: "Checking the sensor",
|
||||
},
|
||||
{
|
||||
type: "assistant_message",
|
||||
id: '["agent_message_chunk","shared-id"]',
|
||||
messageId: "shared-id",
|
||||
text: "It is 25°C",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => {
|
||||
const { connection, events } = await openReplaySession(
|
||||
[
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -23,6 +23,29 @@ describe("plugin protocol compatibility", () => {
|
||||
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
{ type: "plugin.catalog.get.request", requestId: "catalog" },
|
||||
{
|
||||
type: "plugin.catalog.get.request",
|
||||
requestId: "catalog",
|
||||
cachedClientBundles: [{ id: "example", clientBundleHash: "a".repeat(64) }],
|
||||
},
|
||||
])("parses plugin catalog requests %j", (request) => {
|
||||
expect(SessionInboundMessageSchema.parse(request)).toEqual(request);
|
||||
});
|
||||
it("parses plugin catalogs that leave out a client bundle the client already holds", () => {
|
||||
const message = {
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: "catalog",
|
||||
plugins: [
|
||||
{ id: "cached", clientBundleHash: "a".repeat(64) },
|
||||
{ id: "changed", clientBundle: "bundle", clientBundleHash: "b".repeat(64) },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||
});
|
||||
it("preserves manifest metadata in plugin list responses", () => {
|
||||
const message = {
|
||||
type: "plugin.list.response",
|
||||
|
||||
@@ -1470,6 +1470,10 @@ export const DiagnosticsRequestSchema = z.object({
|
||||
export const PluginCatalogGetRequestSchema = z.object({
|
||||
type: z.literal("plugin.catalog.get.request"),
|
||||
requestId: z.string(),
|
||||
/** Client bundles the client already holds; the daemon leaves out each one whose hash matches. */
|
||||
cachedClientBundles: z
|
||||
.array(z.object({ id: PluginIdSchema, clientBundleHash: z.string() }))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const PluginListRequestSchema = z.object({
|
||||
@@ -6757,7 +6761,10 @@ export const PluginCatalogGetResponseSchema = z.object({
|
||||
plugins: z.array(
|
||||
z.object({
|
||||
id: PluginIdSchema,
|
||||
clientBundle: z.string(),
|
||||
/** Left out only when the request listed this plugin with the same `clientBundleHash`. */
|
||||
clientBundle: z.string().optional(),
|
||||
/** SHA-256 of `clientBundle`, hex encoded. */
|
||||
clientBundleHash: z.string().optional(),
|
||||
requirements: PluginRequirementsSchema.optional(),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -107,11 +107,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.13.2",
|
||||
"@getpaseo/highlight": "0.13.2",
|
||||
"@getpaseo/plugin": "0.13.2",
|
||||
"@getpaseo/protocol": "0.13.2",
|
||||
"@getpaseo/relay": "0.13.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/highlight": "0.14.0-beta.1",
|
||||
"@getpaseo/plugin": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -78,6 +78,45 @@ test("listed client bundle is published while plugins are disabled; unlisted dir
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("a repeated catalog fetch leaves out the client bundle the client already holds", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-catalog-cache-"));
|
||||
roots.push(root);
|
||||
const directory = await fixture(root, "large", true);
|
||||
// About the size of a real plugin client bundle.
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`const payload = ${JSON.stringify("x".repeat(1_000_000))};
|
||||
export default function contribute() { void payload; return () => {}; }`,
|
||||
);
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
daemonVersion: "0.9.2",
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins: new BuiltinPluginLoader(root, ["large"]),
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" });
|
||||
const responseBytes: number[] = [];
|
||||
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||
if (message.type === "plugin.catalog.get.response") {
|
||||
responseBytes.push(JSON.stringify(message).length);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const first = await client.getPluginCatalog();
|
||||
const second = await client.getPluginCatalog();
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(second[0]?.clientBundle).toContain("x".repeat(1_000_000));
|
||||
expect(responseBytes).toHaveLength(2);
|
||||
expect(responseBytes[0]).toBeGreaterThan(1_000_000);
|
||||
expect(responseBytes[1]).toBeLessThan(500);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("directory, Git, and npm installs reject a built-in ID", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-install-"));
|
||||
roots.push(root);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -68,9 +69,18 @@ it("rejects incompatible installs without persisting them, then accepts the corr
|
||||
await expect(service.installDirectory({ path: root })).resolves.toMatchObject({
|
||||
status: "running",
|
||||
});
|
||||
expect(service.catalog()).toEqual([
|
||||
{ id: "example", requirements: { paseo: "^0.8.0" }, clientBundle: expect.any(String) },
|
||||
const catalog = service.catalog();
|
||||
expect(catalog).toEqual([
|
||||
{
|
||||
id: "example",
|
||||
requirements: { paseo: "^0.8.0" },
|
||||
clientBundle: expect.any(String),
|
||||
clientBundleHash: expect.any(String),
|
||||
},
|
||||
]);
|
||||
expect(catalog[0]!.clientBundleHash).toBe(
|
||||
createHash("sha256").update(catalog[0]!.clientBundle).digest("hex"),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks pre-0.8 plugins failed on startup and recovers after migration and reload", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { fork } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { PassThrough } from "node:stream";
|
||||
import path from "node:path";
|
||||
@@ -106,6 +107,12 @@ function createReloadChild(
|
||||
};
|
||||
}
|
||||
|
||||
function sha256(text: string | undefined): string {
|
||||
return createHash("sha256")
|
||||
.update(text ?? "")
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function createTestRuntime(
|
||||
dependencies: NonNullable<ConstructorParameters<typeof PluginRuntime>[2]> = {},
|
||||
logger = pino({ level: "silent" }),
|
||||
@@ -651,7 +658,8 @@ lines.on("line", (line) => {
|
||||
type: "timeline.item",
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
id: "assistant-1",
|
||||
id: JSON.stringify(["agent_message_chunk", "assistant-1"]),
|
||||
messageId: "assistant-1",
|
||||
text: "ACP says hi",
|
||||
}),
|
||||
}),
|
||||
@@ -1535,6 +1543,7 @@ export default function contribute(server: { registerProvider(provider: Provider
|
||||
|
||||
expect(spawnChild).not.toHaveBeenCalled();
|
||||
expect(runtime.catalog()[0]?.clientBundle).toContain("addTheme");
|
||||
expect(runtime.catalog()[0]?.clientBundleHash).toBe(sha256(runtime.catalog()[0]?.clientBundle));
|
||||
await runtime.stopAll();
|
||||
});
|
||||
|
||||
@@ -1697,6 +1706,7 @@ export function inspectHost(_input: z.input<typeof inspectRpc.input>) {
|
||||
const plugin = runtime.catalog()[0];
|
||||
expect(plugin?.clientBundle).toContain("Client-only surface");
|
||||
expect(plugin?.clientBundle).not.toContain("node:os");
|
||||
expect(plugin?.clientBundleHash).toBe(sha256(plugin?.clientBundle));
|
||||
await expect(runtime.invoke("split-runtime", "inspect", {})).resolves.toMatchObject({
|
||||
platform: expect.any(String),
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.j
|
||||
import { fork } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type pino from "pino";
|
||||
import {
|
||||
PROVIDER_CAPABILITIES,
|
||||
@@ -74,6 +74,8 @@ interface LoadedPlugin {
|
||||
instanceId: string;
|
||||
requirements: PluginRequirements | undefined;
|
||||
clientBundle: string;
|
||||
/** Lets a client that already holds this bundle skip downloading it again. */
|
||||
clientBundleHash: string;
|
||||
methods: ReadonlySet<string>;
|
||||
hooks: { events: string[]; before: string[] };
|
||||
providers: readonly PluginProviderMetadata[];
|
||||
@@ -246,6 +248,10 @@ function terminatePluginChild(child: PluginChild): void {
|
||||
if (!child.killed) child.kill();
|
||||
}
|
||||
|
||||
function hashClientBundle(clientBundle: string): string {
|
||||
return createHash("sha256").update(clientBundle).digest("hex");
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -369,9 +375,19 @@ export class PluginRuntime {
|
||||
return true;
|
||||
}
|
||||
|
||||
catalog(): Array<{ id: string; clientBundle: string; requirements?: PluginRequirements }> {
|
||||
catalog(): Array<{
|
||||
id: string;
|
||||
clientBundle: string;
|
||||
clientBundleHash: string;
|
||||
requirements?: PluginRequirements;
|
||||
}> {
|
||||
return [...this.plugins.values()]
|
||||
.map(({ id, clientBundle, requirements }) => ({ id, clientBundle, requirements }))
|
||||
.map(({ id, clientBundle, clientBundleHash, requirements }) => ({
|
||||
id,
|
||||
clientBundle,
|
||||
clientBundleHash,
|
||||
requirements,
|
||||
}))
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
@@ -739,10 +755,12 @@ export class PluginRuntime {
|
||||
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
|
||||
const serverBundle = bundles.serverBundle;
|
||||
if (!serverBundle) {
|
||||
const clientBundle = bundles.clientBundle ?? "";
|
||||
return {
|
||||
id: pluginId,
|
||||
instanceId: randomUUID(),
|
||||
clientBundle: bundles.clientBundle ?? "",
|
||||
clientBundle,
|
||||
clientBundleHash: hashClientBundle(clientBundle),
|
||||
requirements: manifest.requirements,
|
||||
methods: new Set(),
|
||||
hooks: { events: [], before: [] },
|
||||
@@ -872,6 +890,7 @@ export class PluginRuntime {
|
||||
id: pluginId,
|
||||
instanceId: randomUUID(),
|
||||
clientBundle,
|
||||
clientBundleHash: hashClientBundle(clientBundle),
|
||||
requirements,
|
||||
methods: new Set(ready.methods),
|
||||
hooks: ready.hooks ?? { events: [], before: [] },
|
||||
|
||||
@@ -588,6 +588,78 @@ test("routes plugin requests and releases its owned catalog subscription on clea
|
||||
expect(releasePluginSubscription).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("leaves out the client bundles a catalog request already holds", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const pluginRuntime: NonNullable<SessionOptions["pluginRuntime"]> = {
|
||||
before: async (_name, request) => request,
|
||||
emit: () => {},
|
||||
listPlugins: async () => [],
|
||||
getLogs: () => [],
|
||||
installDirectory: async () => {
|
||||
throw new Error("Unexpected install");
|
||||
},
|
||||
inspectDirectory: async () => ({ id: "unused" }),
|
||||
reloadPlugin: async () => {
|
||||
throw new Error("Unexpected reload");
|
||||
},
|
||||
enablePlugin: async () => {
|
||||
throw new Error("Unexpected enable");
|
||||
},
|
||||
disablePlugin: async () => {
|
||||
throw new Error("Unexpected disable");
|
||||
},
|
||||
removePlugin: async () => undefined,
|
||||
subscribe: () => () => {},
|
||||
catalog: () => [
|
||||
{ id: "cached", clientBundle: "cached bundle", clientBundleHash: "hash-cached" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
{ id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" },
|
||||
],
|
||||
invokePluginRpc: async () => null,
|
||||
listUsageReports: async () => [],
|
||||
listLegacyUsage: async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }),
|
||||
};
|
||||
const session = createSessionForTest({ messages, pluginRuntime });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "plugin.catalog.get.request",
|
||||
requestId: "reconnect",
|
||||
cachedClientBundles: [
|
||||
{ id: "cached", clientBundleHash: "hash-cached" },
|
||||
{ id: "changed", clientBundleHash: "hash-old" },
|
||||
{ id: "removed", clientBundleHash: "hash-removed" },
|
||||
],
|
||||
});
|
||||
// Clients from before cachedClientBundles always receive every bundle.
|
||||
await session.handleMessage({ type: "plugin.catalog.get.request", requestId: "legacy" });
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: "reconnect",
|
||||
plugins: [
|
||||
{ id: "cached", clientBundleHash: "hash-cached" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
{ id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: "legacy",
|
||||
plugins: [
|
||||
{ id: "cached", clientBundle: "cached bundle", clientBundleHash: "hash-cached" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
{ id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
describe("workspace label subscriptions", () => {
|
||||
type LabelSubscription = Awaited<ReturnType<WorkspaceLabelService["subscribe"]>>;
|
||||
type LabelChange = Parameters<Parameters<WorkspaceLabelService["subscribe"]>[0]["onChange"]>[0];
|
||||
|
||||
@@ -518,7 +518,7 @@ export interface SessionOptions {
|
||||
removePlugin(pluginId: string): Promise<void>;
|
||||
subscribe(listener: (pluginId: string) => void): () => void;
|
||||
subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void;
|
||||
catalog(): Array<{ id: string; clientBundle: string }>;
|
||||
catalog(): Array<{ id: string; clientBundle: string; clientBundleHash?: string }>;
|
||||
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
|
||||
listUsageReports(options?: {
|
||||
forceRefresh?: boolean;
|
||||
@@ -2584,12 +2584,17 @@ export class Session {
|
||||
});
|
||||
}
|
||||
if (msg.type === "plugin.catalog.get.request") {
|
||||
const cached = new Map(
|
||||
msg.cachedClientBundles?.map((entry) => [entry.id, entry.clientBundleHash]),
|
||||
);
|
||||
const plugins = (this.pluginRuntime?.catalog() ?? []).map(({ clientBundle, ...plugin }) =>
|
||||
plugin.clientBundleHash !== undefined && cached.get(plugin.id) === plugin.clientBundleHash
|
||||
? plugin
|
||||
: Object.assign(plugin, { clientBundle }),
|
||||
);
|
||||
this.emit({
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
plugins: this.pluginRuntime?.catalog() ?? [],
|
||||
},
|
||||
payload: { requestId: msg.requestId, plugins },
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, readFile, writeFile, rm, chmod } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
diffWorkspaceGitRefs,
|
||||
@@ -6,14 +10,21 @@ import {
|
||||
fetchWorkspaceGitRemote,
|
||||
parseWorkspaceGitRefs,
|
||||
} from "./workspace-git-fetch.js";
|
||||
import type { RunGitCommand } from "../utils/run-git-command.js";
|
||||
import { runGitCommand, type RunGitCommand } from "../utils/run-git-command.js";
|
||||
|
||||
describe("workspace Git remote refs", () => {
|
||||
test("runs the complete fetch through the supplied Git runner", async () => {
|
||||
const commands: string[][] = [];
|
||||
let refRead = 0;
|
||||
const runGitCommand: RunGitCommand = async (args) => {
|
||||
const run: RunGitCommand = async (args, options) => {
|
||||
commands.push(args);
|
||||
if (args[0] === "fetch") {
|
||||
expect(options.envOverlay).toEqual({
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
GIT_ASKPASS: "",
|
||||
GCM_INTERACTIVE: "0",
|
||||
});
|
||||
}
|
||||
const stdout =
|
||||
args[0] === "for-each-ref"
|
||||
? `refs/remotes/origin/main\0${refRead++ === 0 ? "aaaaaaaa" : "bbbbbbbb"}`
|
||||
@@ -22,7 +33,7 @@ describe("workspace Git remote refs", () => {
|
||||
};
|
||||
|
||||
await expect(
|
||||
fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, runGitCommand),
|
||||
fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, run),
|
||||
).resolves.toMatchObject({
|
||||
changes: [
|
||||
{
|
||||
@@ -109,3 +120,130 @@ describe("workspace Git remote refs", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Serve a real bare repository over Git's dumb HTTP transport, behind Basic authentication.
|
||||
// Both the authentication challenge and successful fetch exercise Git itself.
|
||||
async function withAuthenticatedRemote(
|
||||
verify: (fixture: {
|
||||
cwd: string;
|
||||
run: RunGitCommand;
|
||||
root: string;
|
||||
origin: string;
|
||||
expectedOid: string;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-fetch-auth-"));
|
||||
const cwd = join(root, "checkout");
|
||||
const remote = join(root, "remote.git");
|
||||
const emptyConfig = join(root, "gitconfig");
|
||||
const askpass = join(root, "askpass.sh");
|
||||
const marker = join(root, "prompts");
|
||||
await writeFile(emptyConfig, "");
|
||||
await writeFile(
|
||||
askpass,
|
||||
`#!/bin/sh\nprintf 'prompt\\n' >> '${marker.replaceAll("'", "'\\''")}'\nexit 1\n`,
|
||||
);
|
||||
await chmod(askpass, 0o755);
|
||||
const askpassCommand = askpass;
|
||||
const run: RunGitCommand = (args, options) =>
|
||||
runGitCommand(args, {
|
||||
...options,
|
||||
env: {
|
||||
...options.env,
|
||||
GIT_CONFIG_GLOBAL: emptyConfig,
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_ASKPASS: askpassCommand,
|
||||
SSH_ASKPASS: askpassCommand,
|
||||
},
|
||||
});
|
||||
const server = createServer(async (request, response) => {
|
||||
if (
|
||||
request.headers.authorization !== `Basic ${Buffer.from("fixture:secret").toString("base64")}`
|
||||
) {
|
||||
response.writeHead(401, { "WWW-Authenticate": 'Basic realm="git-test"' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const pathname = new URL(request.url!, "http://localhost").pathname;
|
||||
try {
|
||||
const contents = await readFile(join(root, pathname));
|
||||
response.writeHead(200, { "Content-Type": "application/octet-stream" });
|
||||
response.end(contents);
|
||||
} catch {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
}
|
||||
});
|
||||
try {
|
||||
await run(["init", "--initial-branch=main", cwd], { cwd: root });
|
||||
await run(
|
||||
[
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd },
|
||||
);
|
||||
await run(["clone", "--bare", cwd, remote], { cwd: root });
|
||||
await run(["update-server-info"], { cwd: remote });
|
||||
await run(["config", "credential.helper", ""], { cwd });
|
||||
await run(["config", "core.askPass", askpassCommand], { cwd });
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Expected TCP server");
|
||||
const origin = `http://127.0.0.1:${address.port}/remote.git`;
|
||||
await run(["remote", "add", "origin", origin], { cwd });
|
||||
const expectedOid = (await run(["rev-parse", "HEAD"], { cwd })).stdout.trim();
|
||||
await verify({ cwd, run, root, origin, expectedOid });
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
}
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"background fetches never invoke askpass when credentials are missing",
|
||||
async () => {
|
||||
await withAuthenticatedRemote(async ({ cwd, run, root }) => {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run);
|
||||
expect(String(result.error)).toContain("terminal prompts disabled");
|
||||
}
|
||||
await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"background fetches still use stored credentials",
|
||||
async () => {
|
||||
await withAuthenticatedRemote(async ({ cwd, run, root, origin, expectedOid }) => {
|
||||
const credentials = join(root, "credentials");
|
||||
await writeFile(credentials, origin.replace("http://", "http://fixture:secret@") + "\n");
|
||||
await run(["config", "credential.helper", `store --file="${credentials}"`], { cwd });
|
||||
|
||||
const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.changes).toContainEqual({
|
||||
kind: "added",
|
||||
ref: "origin/main",
|
||||
afterOid: expectedOid,
|
||||
});
|
||||
expect((await run(["rev-parse", "refs/remotes/origin/main"], { cwd })).stdout.trim()).toBe(
|
||||
expectedOid,
|
||||
);
|
||||
await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -33,7 +33,9 @@ export async function fetchWorkspaceGitRemote(
|
||||
try {
|
||||
await run(["fetch", "origin", "--prune"], {
|
||||
cwd,
|
||||
envOverlay: { GIT_TERMINAL_PROMPT: "0" },
|
||||
// Terminal prompting and graphical credential prompting have separate controls.
|
||||
// Keep cached credentials available, but never start a login from a passive fetch.
|
||||
envOverlay: { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", GCM_INTERACTIVE: "0" },
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch (caught) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -3770,3 +3770,60 @@ index eaa3279..05a02eb 100644
|
||||
private:
|
||||
int forceHeightRecalculationCounter_{0};
|
||||
std::shared_ptr<MarkdownTextMeasurementManager> measurementsManager_;
|
||||
diff --git a/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h b/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h
|
||||
--- a/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h
|
||||
+++ b/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h
|
||||
@@ -1639,6 +1639,7 @@
|
||||
struct EnrichedMarkdownMarkdownStyleCodeStruct {
|
||||
std::string fontFamily{};
|
||||
Float fontSize{0.0};
|
||||
+ Float fontScale{0.0};
|
||||
SharedColor color{};
|
||||
SharedColor backgroundColor{};
|
||||
SharedColor borderColor{};
|
||||
@@ -1651,6 +1652,7 @@
|
||||
folly::dynamic result = folly::dynamic::object();
|
||||
result["fontFamily"] = fontFamily;
|
||||
result["fontSize"] = fontSize;
|
||||
+ result["fontScale"] = fontScale;
|
||||
result["color"] = ::facebook::react::toDynamic(color);
|
||||
result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor);
|
||||
result["borderColor"] = ::facebook::react::toDynamic(borderColor);
|
||||
@@ -1670,6 +1672,10 @@
|
||||
if (tmp_fontSize != map.end()) {
|
||||
fromRawValue(context, tmp_fontSize->second, result.fontSize);
|
||||
}
|
||||
+ auto tmp_fontScale = map.find("fontScale");
|
||||
+ if (tmp_fontScale != map.end()) {
|
||||
+ fromRawValue(context, tmp_fontScale->second, result.fontScale);
|
||||
+ }
|
||||
auto tmp_color = map.find("color");
|
||||
if (tmp_color != map.end()) {
|
||||
fromRawValue(context, tmp_color->second, result.color);
|
||||
@@ -6185,6 +6191,7 @@
|
||||
struct EnrichedMarkdownTextMarkdownStyleCodeStruct {
|
||||
std::string fontFamily{};
|
||||
Float fontSize{0.0};
|
||||
+ Float fontScale{0.0};
|
||||
SharedColor color{};
|
||||
SharedColor backgroundColor{};
|
||||
SharedColor borderColor{};
|
||||
@@ -6197,6 +6204,7 @@
|
||||
folly::dynamic result = folly::dynamic::object();
|
||||
result["fontFamily"] = fontFamily;
|
||||
result["fontSize"] = fontSize;
|
||||
+ result["fontScale"] = fontScale;
|
||||
result["color"] = ::facebook::react::toDynamic(color);
|
||||
result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor);
|
||||
result["borderColor"] = ::facebook::react::toDynamic(borderColor);
|
||||
@@ -6216,6 +6224,10 @@
|
||||
if (tmp_fontSize != map.end()) {
|
||||
fromRawValue(context, tmp_fontSize->second, result.fontSize);
|
||||
}
|
||||
+ auto tmp_fontScale = map.find("fontScale");
|
||||
+ if (tmp_fontScale != map.end()) {
|
||||
+ fromRawValue(context, tmp_fontScale->second, result.fontScale);
|
||||
+ }
|
||||
auto tmp_color = map.find("color");
|
||||
if (tmp_color != map.end()) {
|
||||
fromRawValue(context, tmp_color->second, result.color);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -10,6 +10,38 @@ const bin = path.resolve("node_modules", ".bin");
|
||||
const packageName = "react-native-draggable-flatlist";
|
||||
const patchFile = `${packageName}+4.0.3.patch`;
|
||||
|
||||
test("patched Markdown ships iOS props generated from its native specs", () => {
|
||||
const packageDir = path.resolve("packages/app/node_modules/react-native-enriched-markdown");
|
||||
const output = mkdtempSync(path.join(tmpdir(), "markdown-ios-codegen-"));
|
||||
try {
|
||||
// includesGeneratedCode makes CocoaPods use the shipped headers instead of
|
||||
// regenerating them after patch-package changes the TypeScript specs.
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve("node_modules/react-native/scripts/generate-codegen-artifacts.js"),
|
||||
"-p",
|
||||
packageDir,
|
||||
"-t",
|
||||
"ios",
|
||||
"-o",
|
||||
output,
|
||||
"-s",
|
||||
"library",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
const header = "ReactCodegen/EnrichedMarkdownTextSpec/Props.h";
|
||||
assert.equal(
|
||||
readFileSync(path.join(packageDir, "ios/generated", header), "utf8"),
|
||||
readFileSync(path.join(output, header), "utf8"),
|
||||
);
|
||||
} finally {
|
||||
rmSync(output, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createCheckout() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "postinstall-patches-"));
|
||||
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "checkout" }));
|
||||
|
||||
Reference in New Issue
Block a user