Compare commits

...
Author SHA1 Message Date
Mohamed Boudra aae97b2bdc fix(app): regenerate iOS Markdown inline code props
(cherry picked from commit b3ca2327a8)
2026-10-27 16:45:05 +01:00
Mohamed Boudra 8419c21227 docs(changelog): explain Claude subscription-limit workaround 2026-10-27 14:43:03 +01:00
paseo-ai[bot] 5f014498c7 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-27 13:32:06 +00:00
Mohamed Boudra 4495bda146 chore(release): cut 0.14.0-beta.1 2026-10-27 14:21:19 +01:00
Mohamed Boudra 484c5ba224 docs(release): prepare 0.14.0-beta.1 notes 2026-10-27 14:15:48 +01:00
Mohamed Boudra 1b8dbc38ab fix(app): preserve chat reading position across older pages (#8026)
* fix(app): preserve reading position when older turns fold

Item-based history pages can complete and collapse the row at the reading position. Transfer its anchor to the folded turn host so reaching history start does not drain older pages. Also omit reasoning from action summaries and align sender attribution with its message bubble.

* fix(app): preserve the reader when older pages extend tool groups

Resolve grouped calls and folded turns through one presentation-owned row lookup. Keep retained group indexing off the streaming path and verify item-page group replacement against its failing baseline.

* fix(app): preserve message previews when opening turns

Keep the wrapper child structure stable so React does not remount the message fragment when the indent rail appears. Remounting reset preview measurements and briefly removed the fade and Show more control.

* fix(app): separate show less from expanded message text
2026-10-27 19:25:13 +07:00
paseo-bot[bot] 694696e82c fix(server): keep background Git fetches from opening login prompts (#8019)
* fix(server): keep background Git fetches from opening login prompts

Git tries graphical askpass before consulting GIT_TERMINAL_PROMPT, so passive fetches repeatedly opened a login despite terminal prompting being disabled. Disable askpass and credential-manager interaction for this fetch while preserving stored credential lookup.

* test(server): make fetch and ACP expectations match supported Git and timeline behavior

Newer Git also creates origin/HEAD on the first fetch, so assert the authenticated branch result without excluding that valid additional ref. Main now qualifies ACP timeline identities by chunk kind while preserving the native messageId; update the plugin round-trip test to check that contract.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-27 12:00:55 +00:00
paseo-bot[bot]andJacek Tomaszewski a8032a1502 fix(app): open outside folder links without breaking Files (#7726)
Outside folder links were routed to the workspace-scoped Files explorer,
which kept an access error until Retry. Route explicit outside folders
to the local file manager or show an outside-workspace message, while
preserving existing file and inside-folder behavior.

Refs #7513

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Jacek Tomaszewski <jacek@fullstack.house>
2026-10-27 10:57:58 +00:00
paseo-bot[bot]andYanlin dc07efe937 Keep plugin ACP reasoning separate from assistant replies (#7272)
* fix(plugin): keep ACP reasoning separate from replies

Scope explicit timeline identities by chunk kind so a provider sharing a native message ID cannot merge reasoning into the assistant reply. Preserve the native assistant messageId for consumers.

Co-authored-by: Yanlin <127009+cncolder@users.noreply.github.com>

* test(plugin): cover kind-qualified ACP history identities

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Yanlin <127009+cncolder@users.noreply.github.com>
2026-10-27 09:53:42 +00:00
paseo-ai[bot] 36892d5be7 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-27 09:31:18 +00:00
57 changed files with 1271 additions and 141 deletions
+60
View File
@@ -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
+60
View File
@@ -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
### 修复
+1 -1
View File
@@ -1 +1 @@
sha256-dk7kJUdmQBF8L8Vkj6Ryza3n4/S+YqgxWY3R59Dc3mY=
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
+28 -28
View File
@@ -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
View File
@@ -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();
}
});
+133 -1
View File
@@ -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,9 +470,10 @@ 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();
}
@@ -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 (
@@ -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 -1
View File
@@ -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");
+51 -10
View File
@@ -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,7 +107,7 @@ export function createStreamPresentation() {
function collapse(
projection: ToolCallDetailProjection,
input: PresentationInput,
): StreamPresentation {
): Omit<StreamPresentation, "readingRowReplacements"> {
if (input.display !== "collapseTurns") {
return { ...projection, turns: NO_COLLAPSED_TURNS };
}
@@ -157,19 +175,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);
+16 -7
View File
@@ -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,
};
}
+6 -3
View File
@@ -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();
@@ -1203,6 +1206,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({
readingRowReplacements: presentation.readingRowReplacements,
agentId,
segments: renderModel.segments,
historyRowRevision,
@@ -1895,12 +1899,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,
@@ -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] },
}));
+1
View File
@@ -67,6 +67,7 @@ export const ar: TranslationResources = {
daemonClientUnavailable: "عميل Daemon غير متوفر",
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
},
connectionStatus: {
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -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: {
+1
View File
@@ -68,6 +68,7 @@ export const ja: TranslationResources = {
daemonClientUnavailable: "デーモンクライアントが利用できません",
daemonClientDisconnected: "デーモンクライアントが切断されています",
noFileFound: "{{token}}のファイルが見つかりません",
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
},
connectionStatus: {
+1
View File
@@ -67,6 +67,7 @@ export const ko: TranslationResources = {
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
},
connectionStatus: {
+1
View File
@@ -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: {
+1
View File
@@ -68,6 +68,7 @@ export const ru: TranslationResources = {
daemonClientUnavailable: "Daemon клиента недоступен",
daemonClientDisconnected: "Daemon клиента отключен",
noFileFound: "Файл для {{token}} не найден",
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
},
connectionStatus: {
+1
View File
@@ -67,6 +67,7 @@ export const zhCN: TranslationResources = {
daemonClientUnavailable: "Daemon client 不可用",
daemonClientDisconnected: "Daemon client 已断开连接",
noFileFound: "未找到 {{token}} 对应的文件",
folderOutsideWorkspace: "{{path}} 不在此工作区内",
unexpectedDictationError: "处理听写时发生意外错误。",
},
connectionStatus: {
@@ -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();
@@ -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]);
}
@@ -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],
);
}
+4 -4
View File
@@ -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",
+3 -3
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.13.2",
"version": "0.14.0-beta.1",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -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) {
+77 -4
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+6 -6
View File
@@ -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",
@@ -651,7 +651,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",
}),
}),
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.13.2",
"version": "0.14.0-beta.1",
"private": true,
"type": "module",
"scripts": {
+32
View File
@@ -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" }));