Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 9bf04b7351 fix(server): retain restart-safe images with async processing
Integrate current main while preserving stable attachment storage, async materialization, bounded decoding and atomic publication. Adapt the restart regression and private-storage tests to the asynchronous API and isolate all materialization tests from the real home.
2026-10-27 09:30:35 +00:00
paseo-bot[bot]andChristoph Leiter 89d1e0cd2e fix(server): reuse provider image files across daemon restarts
Integrate persistent provider-image storage with current main while retaining image paragraph boundaries. Replaying identical image bytes must reuse the existing content-addressed file instead of leaving another temporary copy after each restart.

Co-authored-by: Christoph Leiter <christoph@leiter.io>
2026-10-26 04:18:01 +00:00
Christoph Leiter a6f975f49a fix(server): stop sweeping materialized images by age
The 30-day sweep reintroduced the bug this PR fixes, on a longer fuse.
mtime cannot distinguish an image a transcript still references from an
abandoned one, and reading a transcript never touches the file, so an
image you still look at ages out and disappears.

It was also a regression on hosts whose temp dir is not reaped on a
schedule. There these images survived indefinitely; adding our own
deleter made them mortal.

Nothing deletes them now. Content addressing bounds growth to distinct
images rather than renders. Bounding the directory properly needs to be
reference-aware, against the agent timelines that point into it.
2026-07-30 06:05:50 +02:00
Christoph Leiter ba42900d1a test(server): keep POSIX mode assertions out of the cross-platform suite
Windows ignores mkdir/writeFile modes and reports 0o666, so the directory
and file mode assertions failed on the windows-latest runner. The .posix
suite already covers both and skips on win32.
2026-07-30 06:05:50 +02:00
Christoph LeiterandClaude Opus 4.8 14ec5a8ae9 fix(server): materialize provider images under the paseo home
Provider images were materialized into a per-process directory created
with mkdtemp(os.tmpdir(), "paseo-attachments-"). The OS reaps that
location -- macOS clears /var/folders after roughly three days -- so
every image in a transcript older than the reap window was silently
orphaned: the stored markdown still pointed at a path whose bytes no
longer existed, and nothing could recreate them.

Materialize into resolvePaseoHome()/paseo-attachments/ instead. The
directory keeps its 0700 mode and the files their 0600 mode, and the
basename is unchanged so PROVIDER_IMAGE_MARKDOWN still matches history
written by older builds (the optional "-<suffix>" group covers the old
mkdtemp shape).

A stable directory is no longer self-limiting, so add a best-effort
sweep of entries older than 30 days, run once per process on the first
image materialized. Re-materializing an image rewrites it and refreshes
its mtime, so images still being rendered keep themselves alive and only
genuinely cold ones age out.

This also makes the existing content-hash filename deduplication work
across daemon restarts; previously it only held within a single process.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 06:05:50 +02:00
142 changed files with 1308 additions and 3892 deletions
+18 -11
View File
@@ -133,12 +133,11 @@ jobs:
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run typecheck:examples --workspace=@getpaseo/client
# The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04.
typecheck:
name: typecheck
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -396,7 +395,7 @@ jobs:
name: app-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -418,11 +417,19 @@ jobs:
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Run Android module tests
run: npm run test:android --workspace=@getpaseo/app
playwright-1:
name: playwright (shard 1/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "1/8"
PLAYWRIGHT_ARTIFACT: "1"
@@ -478,7 +485,7 @@ jobs:
name: playwright (shard 2/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "2/8"
PLAYWRIGHT_ARTIFACT: "2"
@@ -488,7 +495,7 @@ jobs:
name: playwright (shard 3/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "3/8"
PLAYWRIGHT_ARTIFACT: "3"
@@ -498,7 +505,7 @@ jobs:
name: playwright (shard 4/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "4/8"
PLAYWRIGHT_ARTIFACT: "4"
@@ -508,7 +515,7 @@ jobs:
name: playwright (shard 5/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "5/8"
PLAYWRIGHT_ARTIFACT: "5"
@@ -518,7 +525,7 @@ jobs:
name: playwright (shard 6/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "6/8"
PLAYWRIGHT_ARTIFACT: "6"
@@ -528,7 +535,7 @@ jobs:
name: playwright (shard 7/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "7/8"
PLAYWRIGHT_ARTIFACT: "7"
@@ -538,7 +545,7 @@ jobs:
name: playwright (shard 8/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "8/8"
PLAYWRIGHT_ARTIFACT: "8"
-63
View File
@@ -1,68 +1,5 @@
# Changelog
## 0.14.0-beta.2 - 2026-10-28
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 Chat display settings that group tool calls or collapse 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), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) 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))
- Opened subagents in place of the subagent list on phones, with Back returning to the list ([#8093](https://github.com/getpaseo/paseo/pull/8093))
- 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), [#8088](https://github.com/getpaseo/paseo/pull/8088))
- Fixed compact navigation sizes and a cramped Settings detail pane in docked windows 600–719 wide ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
- 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 discovery errors touching the model picker edges ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
- 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
-63
View File
@@ -1,68 +1,5 @@
# 更新日志
## 0.14.0-beta.2 - 2026-10-28
插件现在可以为单个 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), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) 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))
- 在手机上从子 Agent 列表打开子 Agent 时直接替换列表,点按“返回”可回到列表 ([#8093](https://github.com/getpaseo/paseo/pull/8093))
- 问题卡片新增可点击链接和可选中的描述文本 ([#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), [#8088](https://github.com/getpaseo/paseo/pull/8088))
- 修复宽度 600–719 的停靠布局使用紧凑导航尺寸、设置详情面板过窄的问题 ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#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))
- 修复模型发现错误信息紧贴模型选择器边缘的问题 ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
- 修复模型选择器在打开设置后仍然显示的问题 ([#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
@@ -288,7 +288,7 @@ parentAgentId === thisAgent.id AND !archivedAt
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) instead, replacing the subagents list in place (Back returns to the list); the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
+1 -1
View File
@@ -98,7 +98,7 @@ The older `android`, `android:development`, and `ios` commands remain aliases fo
`android:production` still builds and installs a release through Expo; use `build:apk` when you
need an artifact. `android:clear` removes the generated Android project.
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain.
### Diagnostic logs without ADB
+4 -6
View File
@@ -150,20 +150,18 @@ One row off the rail makes the whole card look unconsidered.
Compact-first. The small case is designed; the large case adds chrome around it.
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On wide layouts: a sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
On a narrow window using the wide layout, app navigation yields when the remaining width cannot accommodate the content topology. Settings prefers a 720px split (320px list + 400px detail); its list shrinks first to keep forms readable without reducing the detail below 400px. Explorer reserves at least 240px plus a 360px workspace. These are local space budgets, not compact breakpoints. The app sidebar shrinks from its preferred 320px toward a 200px minimum to leave a 400px center target; its preferred width fits at 720px. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
+13 -24
View File
@@ -33,36 +33,25 @@ keeps only agents and terminals. A panel kind opts in with `compactSheet` in its
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one
ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet lands on
top of it, and closing the top sheet (drag, Close, backdrop, or Android Back) uncovers the one below.
A subagent chosen from the subagents list is the exception. It opens as a page of the list's own
sheet and replaces the list in place: Back returns to the list, Close ends both, and Open parent
closes the list, landing on the pane the list was opened from. The store's `selectSheetTab` describes
the `SheetTab` a reveal would show without opening it, so the page never enters `sheetsByWorkspace`.
See [menus.md](menus.md#a-page-that-fills-the-sheet) for the page contract.
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a
subagent chosen from the subagents list included, lands on top of it, and closing the top sheet
(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the
sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract.
The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet
remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
Both kinds render the same `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`): the
header (icon, title, Back when there is a list to return to, Open as tab, Close), the pane, its
bottom inset, and its focus. Only the container differs, a workspace sheet in `sheet-tabs.tsx` or a
menu page in `sheet-pane/page.tsx`, so a fix to what a sheet shows lands in one place. The
container owns Android Back; the pane never subscribes to it.
Sheets are modal and cover both compact sidebars as well as the workspace. The header drags the
sheet through `SheetDragArea`, because the panel scrolls on its own and content panning would fight
it. Its trailing rail holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the
full-screen glyph beside Close, so the title keeps its width. Compact sheets use the app overlay
host: a workspace-local host sits behind the app-level left sidebar. The sheet pane bridge
(`useSheetPaneBridge`) preserves existing editing sessions, caches, file-link resolution and
supported navigation context without moving their providers. Keep this capability at the workspace
boundary; new sheet callers must not assemble provider internals. Wide layouts retain local hosting
and pane geometry. Both hosts forward the same Gorhom modal queue; neither creates another stack.
See
Sheets are modal and cover both compact sidebars as well as the workspace. A sheet's header is its drag
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
Close, so the title keeps its width. Compact sheets use the app overlay host: a workspace-local
host sits behind the app-level left sidebar. The workspace environment bridge preserves existing
editing sessions, caches, file-link resolution and supported navigation context without moving
their providers. Keep this capability at the workspace boundary; new sheet callers must not
assemble provider internals. Wide layouts retain local hosting and pane geometry. Both hosts
forward the same Gorhom modal queue; neither creates another stack. See
[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site).
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
-13
View File
@@ -410,19 +410,6 @@ bridges the menu's two contexts; the rest pass `null`.
Wrapping providers _around_ the modal does nothing. They land on the wrong side.
## Gotcha 8 — Read safe-area insets where the overlay draws
A compact sheet tab's body (`SheetBottomInset` in `adaptive-modal-sheet.tsx`) pads the bottom
inset and gives its content a bottom inset of 0, so the composer dock sits the same in a sheet as
in a tab. An overlay opened from that content and calling `useSafeAreaInsets()` at its call site
gets that 0 and draws over the navigation bar.
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
inside it and read the inset below that. The modal is its own window, and the provider measures
it. See `attachment-lightbox.tsx`.
## Recipe for a new anchored panel
Before you write a new one, ask:
+1 -1
View File
@@ -22,7 +22,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md).
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. A subagent chosen from the subagents list shows as a page of the list's sheet instead of a sheet of its own. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
+4 -23
View File
@@ -52,12 +52,10 @@ is another, with its own icons and hover-revealed actions — but both hand the
and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press
outright leaves the surface open behind whatever it opened, and skips the iOS wait.
A row whose result opens a sheet opens it as a page of the menu's own sheet, never as a second
sheet over it: the row names a [fill page](#a-page-that-fills-the-sheet) with `opensPage`, the page
replaces the list in place, Back returns to the list, and Close ends both. A row that opens a tab
A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu
sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab
closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout
store which sheet tab a row would show (`useSubagentSheetTab`), since the answer changes with the
layout; a row with none opens its tab.
store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout.
## Pages
@@ -103,23 +101,6 @@ The sheet keeps `keyboardBehavior="interactive"`. `extend` grows a sheet to its
point, and with `enableDynamicSizing` that point is the content's own height — a short page does
not grow, and the keyboard comes up over the field you are typing into.
## A page that fills the sheet
A page can carry its own content instead of rows: `{ id, fill: true, content }`. Only the sheet
renders one. It takes the full-sheet snap point (`FULL_SHEET_SNAP_POINTS`) instead of sizing to
content, and the sheet stops panning on content drags, because the page scrolls on its own and a
drag would fight that scroll. The page's header drags the sheet instead, through `SheetDragArea`.
The content renders on the far side of the sheet's portal, so it brings its own context bridge,
built where the page is defined. Sheet tabs are the one user: `useSheetPanePage` turns a `SheetTab`
into a fill page around the same `SheetPane` the workspace's own sheets render. See
[explorer-sidebar.md](explorer-sidebar.md#sheets).
Back pops one page. The header's Back and Android Back both call `goBack`, which answers `false` at
the root so the sheet closes instead. A page whose definition disappears while it is open, such as
a subagent archived from elsewhere, is pruned from the path, and the sheet falls back to the page
that opened it.
## Hover intent
A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
@@ -208,7 +189,7 @@ its own.
entering animation never ends at the off-screen measuring position; on a slow machine that
write moved the open menu back off-screen.
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it. A fill page is the one exception.
rarely the height of the page before it.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts
them on the wrong side of the portal and every item inside throws. Gotcha 7 in
+1 -1
View File
@@ -1 +1 @@
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
sha256-dk7kJUdmQBF8L8Vkj6Ryza3n4/S+YqgxWY3R59Dc3mY=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37590,7 +37590,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39793,12 +39793,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/server": "0.14.0-beta.2",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/server": "0.13.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -40045,10 +40045,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"dependencies": {
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -40060,7 +40060,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40304,7 +40304,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40350,7 +40350,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -40379,15 +40379,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@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.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"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.14.0-beta.2",
"version": "0.13.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -40447,7 +40447,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -40668,16 +40668,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/highlight": "0.14.0-beta.2",
"@getpaseo/plugin": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.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",
"@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.14.0-beta.2",
"version": "0.13.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43208,7 +43208,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -27,10 +27,6 @@ import {
seedRealisticOlderTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
expectToolCallGroupPositionPreserved,
rememberToolCallGroupPosition,
seedItemPagedToolGroupTimeline,
useChatDisplay,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
@@ -104,32 +100,6 @@ 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,16 +5,12 @@ import { expectComposerVisible } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
expectSubagentRowGone,
expectSubagentRowVisible,
openSubagentFromCompactTrack,
seedSubagent,
} from "../support/helpers/subagents";
import {
backToList,
closeSheet,
expectNoSheetOpen,
expectOneSheetOpen,
expectOpenTabCount,
openSheetAsTab,
expectDeclinedDiscardPrompt,
@@ -56,10 +52,6 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Sheet child" },
);
const grandchild = await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: child.id, title: "Sheet grandchild" },
);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await expectOpenTabCount(page, 1);
@@ -87,14 +79,12 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await tapSheetBackdrop(page);
});
await test.step("a subagent opens in place of the subagents list", async () => {
await test.step("a subagent opens in a sheet over the subagents list", async () => {
await openSubagentFromCompactTrack(page, child.id);
const childSheet = sheet(page, child.title);
await expect(childSheet).toBeVisible();
await expectSheetAtSnap(page, child.title);
await expectSubagentRowGone(page, child.id);
await expectOneSheetOpen(page);
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
// seeded agent has no context usage yet.
@@ -104,51 +94,23 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expectOpenTabCount(page, 1);
});
await test.step("Back returns the sheet to the list", async () => {
await backToList(page, child.title);
await expectSubagentRowVisible(page, child.id);
await expectOneSheetOpen(page);
});
await test.step("Close closes the list with the subagent", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await test.step("closing the subagent's sheet uncovers the list", async () => {
await closeSheet(page, child.title);
await expectSubagentRowGone(page, child.id);
await expectNoSheetOpen(page);
await expectSubagentRowVisible(page, child.id);
});
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
await openSubagentFromCompactTrack(page, child.id);
await page.getByTestId(`subagents-track-row-${child.id}`).click();
const childSheet = sheet(page, child.title);
await expectSheetAtSnap(page, child.title);
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
await childSheet.getByTestId("parent-agent-callout-open").click();
await expect(childSheet).toHaveCount(0);
await expectNoSheetOpen(page);
await expectOpenTabCount(page, 1);
});
await test.step("a nested subagent's parent box lands on its parent's sheet", async () => {
await openSubagentFromCompactTrack(page, child.id);
const childSheet = sheet(page, child.title);
await openSubagentFromCompactTrack(page, grandchild.id, childSheet);
const grandchildSheet = sheet(page, grandchild.title);
await expectSheetAtSnap(page, grandchild.title);
await expect(grandchildSheet.getByTestId("parent-agent-callout")).toContainText(child.title);
await grandchildSheet.getByTestId("parent-agent-callout-open").click();
await expect(grandchildSheet).toHaveCount(0);
await expect(childSheet).toBeVisible();
await expectOneSheetOpen(page);
await childSheet.getByTestId("parent-agent-callout-open").click();
await expect(childSheet).toHaveCount(0);
await expectNoSheetOpen(page);
await expectOpenTabCount(page, 1);
});
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
await openSubagentFromCompactTrack(page, child.id);
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await expectSheetAtSnap(page, child.title);
await openSheetAsTab(page, child.title);
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
@@ -161,7 +123,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
});
await test.step("once its tab closes, the subagent reopens in place of the list", async () => {
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
await page
.getByRole("button", { name: /Switch tabs/ })
.filter({ visible: true })
@@ -179,7 +141,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await openSubagentFromCompactTrack(page, child.id);
await expectSheetAtSnap(page, child.title);
await backToList(page, child.title);
await closeSheet(page, child.title);
await expectSubagentRowVisible(page, child.id);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
@@ -5,86 +5,22 @@ import {
getTerminalBufferText,
} from "../support/helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { metroTest as test, expect, type Page } from "../support/fixtures";
import { metroTest as test, expect } 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 }) => {
@@ -98,18 +34,43 @@ test("loads history across browser reconnection while retaining old-daemon conne
observationSockets.add(socket);
});
});
const published = await startPublishedDaemonAgent({
publishedVersion: "0.2.5",
serverIdPrefix: "srv_old_pagination_",
const workspace = await seedWorkspace({
repoPrefix: "timeline-old-daemon-pagination-",
title: "Published daemon pagination regression",
port: daemon.port,
});
const { agent, serverId } = published;
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,
};
try {
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
await agent.client.waitForFinish(agent.agentId, 15_000);
await published.seedBrowser(page);
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 openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
@@ -126,7 +87,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, agent.workspaceId);
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
await focusTerminalSurface(page);
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
@@ -136,46 +97,7 @@ test("loads history across browser reconnection while retaining old-daemon conne
await agent.client.killTerminal(terminalId);
});
} finally {
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();
await agent.cleanup();
await daemon.close();
}
});
@@ -1,7 +1,5 @@
import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -216,66 +214,3 @@ test.describe("provider settings overlay stack", () => {
}
});
});
async function openTimedOutProvider(page: Page, workspaceId: string, width: number) {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
await page.setViewportSize({ width, height: 900 });
await page.getByRole("button", { name: /Select model/ }).click();
if (width < 600) {
await page.getByTestId("agent-controls-model").click();
} else {
await page.getByRole("button", { name: "Back", exact: true }).click();
}
await page.getByTestId("model-provider-picker-timeout").click();
await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible();
}
async function expectErrorHasSideGutters(page: Page) {
const error = page.getByText(/Timed out refreshing Pi timeout/);
const bounds = await error.evaluate((element) => {
const text = element.getBoundingClientRect();
const state = element.parentElement!.getBoundingClientRect();
return { left: text.left - state.left, right: state.right - text.right };
});
expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0);
expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0);
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
}
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
catalogRefreshTimeoutMs: 2000,
providers: {
"picker-timeout": {
extends: "pi",
label: "Pi timeout",
enabled: true,
command: [
process.execPath,
path.resolve("e2e/fixtures/fake-pi-rpc.mjs"),
"--stall-model-discovery",
],
},
},
},
},
});
test.describe("model discovery timeout", () => {
for (const width of [1200, 390]) {
test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({
page,
withWorkspace,
}, testInfo) => {
await page.setViewportSize({ width: 1200, height: 900 });
const workspace = await withWorkspace({ prefix: "model-error-spacing-" });
await openTimedOutProvider(page, workspace.workspaceId, width);
await page.screenshot({ path: testInfo.outputPath("model-error.png") });
await expectErrorHasSideGutters(page);
});
}
});
@@ -479,76 +479,3 @@ test.describe("Settings — compact master-detail", () => {
await expect(page.getByTestId("welcome-direct-connection")).toBeVisible();
});
});
async function measureDockedNavigation(page: import("@playwright/test").Page, width: number) {
await page.setViewportSize({ width, height: 951 });
const history = page.getByRole("button", { name: /^History/ }).filter({ visible: true });
const settings = page
.getByRole("button", { name: "Settings", exact: true })
.filter({ visible: true });
await expect(history).toBeVisible();
await expect(settings).toBeVisible();
return {
width,
history: await history.evaluate((el) => el.getBoundingClientRect().height),
settings: await settings.evaluate((el) => el.getBoundingClientRect().height),
};
}
async function measureSettingsSplit(page: import("@playwright/test").Page, width: number) {
await page.setViewportSize({ width, height: 951 });
const sidebar = page.getByTestId("settings-sidebar");
const detail = page.getByTestId("settings-detail-pane");
await expect(sidebar).toBeVisible();
await expect(detail).toBeVisible();
return {
width,
sidebar: await sidebar.evaluate((el) => el.getBoundingClientRect().width),
detail: await detail.evaluate((el) => el.getBoundingClientRect().width),
};
}
async function expectConsistentDockedNavigation(page: import("@playwright/test").Page) {
const wide = await measureDockedNavigation(page, 951);
for (const width of [600, 669, 719, 720]) {
const narrow = await measureDockedNavigation(page, width);
expect
.soft(narrow.history, `History keeps its wide-layout density at ${width}`)
.toBeCloseTo(wide.history, 0);
expect
.soft(narrow.settings, `Settings keeps its wide-layout density at ${width}`)
.toBeCloseTo(wide.settings, 0);
}
}
async function expectSettingsDetailKeepsReadingWidth(page: import("@playwright/test").Page) {
const reference = await measureSettingsSplit(page, 720);
for (const width of [600, 669, 719]) {
const narrow = await measureSettingsSplit(page, width);
expect
.soft(narrow.detail, `Settings preserves its detail budget at ${width}`)
.toBeGreaterThanOrEqual(reference.detail);
expect
.soft(narrow.sidebar, `Settings navigation yields width at ${width}`)
.toBeLessThan(reference.sidebar);
}
const wide = await measureSettingsSplit(page, 951);
expect(wide.sidebar).toBeCloseTo(reference.sidebar, 0);
expect(wide.detail).toBeGreaterThan(reference.detail);
}
test("docked navigation keeps its density and Settings preserves reading width", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "narrow-wide-layout-" });
await gotoWorkspace(page, workspace.workspaceId);
await expectConsistentDockedNavigation(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
await expectAppearanceContent(page);
await expectSettingsDetailKeepsReadingWidth(page);
await page.setViewportSize({ width: 599, height: 951 });
await expectSettingsSidebarHidden(page);
await expectAppearanceContent(page);
});
@@ -1,15 +1,8 @@
import type { Locator, Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
import { gateSecondToolCall } from "../support/helpers/tool-call-gate";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
readReactRenders,
recordReactRenders,
resetReactRenders,
} from "../support/helpers/react-renders";
type WebSocketMessage = string | Buffer;
@@ -122,27 +115,23 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
});
});
function appendCompletedCalls(extraCompletedCalls: number) {
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
throw new Error("Expected the first completed tool call before release");
}
for (let index = 0; index < extraCompletedCalls; index += 1) {
sendToPage?.(
cloneToolCallWithId(
firstCompletedMessage as WebSocketMessage,
`${firstCallId}:${sequenceOffset + index}`,
sequenceOffset + index + 1,
),
);
}
sequenceOffset += extraCompletedCalls;
}
return {
waitForFirstCompleted: () => firstCompleted,
appendCompletedCalls,
release(extraCompletedCalls = 0) {
isHolding = false;
appendCompletedCalls(extraCompletedCalls);
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
throw new Error("Expected the first completed tool call before release");
}
for (let index = 0; index < extraCompletedCalls; index += 1) {
sendToPage?.(
cloneToolCallWithId(
firstCompletedMessage as WebSocketMessage,
`${firstCallId}:${index}`,
index + 1,
),
);
}
sequenceOffset = extraCompletedCalls;
for (const message of heldMessages.splice(0)) {
sendToPage?.(shiftTimelineSequence(message, sequenceOffset));
}
@@ -150,24 +139,17 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
};
}
type OverviewChatDisplay = "collapseToolCalls" | "collapseTurns";
// Expand thinking keeps reasoning out of tool call runs, so the first tool call starts alone.
async function configureOverviewToolCalls(page: Page, chatDisplay: OverviewChatDisplay) {
await page.addInitScript((display) => {
async function configureOverviewToolCalls(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ chatDisplay: display, autoExpandReasoning: true }),
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
);
}, chatDisplay);
});
}
async function createOverviewAgent(
page: Page,
title: string,
chatDisplay: OverviewChatDisplay = "collapseToolCalls",
) {
await configureOverviewToolCalls(page, chatDisplay);
async function createOverviewAgent(page: Page, title: string) {
await configureOverviewToolCalls(page);
return seedMockAgentWorkspace({
repoPrefix: "tool-call-overview-sheet-",
title,
@@ -222,36 +204,6 @@ function readScrollMetrics(root: HTMLElement) {
};
}
const FIRST_CALL_DETAILS = "export function ConversationList()";
function firstToolCall(page: Page): Locator {
return page.getByTestId("tool-call-badge").filter({ hasText: "conversation-list.tsx" }).first();
}
async function openToolDetails(badge: Locator): Promise<void> {
await badge.getByRole("button").first().click();
}
async function timelineRowOf(locator: Locator) {
const row = await locator.locator("xpath=ancestor::*[@data-history-row-id][1]").elementHandle();
if (!row) throw new Error("Expected the locator inside a timeline row");
return row;
}
/** Whether the locator renders inside this exact row node, i.e. the row was not remounted. */
async function isInsideRow(
locator: Locator,
row: Awaited<ReturnType<typeof timelineRowOf>>,
): Promise<boolean> {
return locator.evaluate((node, previousRow) => previousRow.contains(node), row);
}
async function topOf(row: Locator): Promise<number> {
const box = await row.boundingBox();
if (!box) throw new Error("Expected a visible timeline row");
return box.y;
}
test.describe("compact overview tool calls", () => {
test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true });
@@ -267,16 +219,8 @@ test.describe("compact overview tool calls", () => {
await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet.");
await gate.waitForFirstCompleted();
const single = firstToolCall(page);
await expect(single).toBeVisible();
await expect(page.getByTestId("tool-call-group")).toHaveCount(0);
await openToolDetails(single);
await expect(page.getByTestId("tool-call-sheet-close")).toBeVisible();
await page.getByTestId("tool-call-sheet-close").click();
gate.appendCompletedCalls(1);
const group = page.getByTestId("tool-call-group").first();
await expect(group).toBeVisible();
await expect(page.getByTestId("tool-call-group-sheet")).toBeHidden();
await group.click();
const sheet = page.getByTestId("tool-call-group-sheet");
@@ -317,86 +261,27 @@ test.describe("compact overview tool calls", () => {
});
});
for (const display of ["collapseToolCalls", "collapseTurns"] as const) {
test(`keeps an opened lone tool call open when it becomes a group (${display})`, async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await createOverviewAgent(page, "Desktop overview tool calls", display);
await recordReactRenders(page);
try {
const gate = await gateSecondToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
await gate.waitForFirstCompleted();
const lone = firstToolCall(page);
const group = page.getByTestId("tool-call-group").first();
const details = page.getByText(FIRST_CALL_DETAILS);
await test.step("a lone tool call shows as itself", async () => {
await expect(lone).toBeVisible();
await expect(group).toHaveCount(0);
});
await gate.waitForSecondRunning();
await test.step("opening it re-renders that tool call and nothing else", async () => {
await resetReactRenders(page);
await openToolDetails(lone);
await expect(details).toBeVisible();
const renders = await readReactRenders(page);
expect(renders.AgentStreamView ?? 0).toBe(0);
expect(renders.StreamRowContent ?? 0).toBe(0);
expect(renders.ToolCall).toBe(1);
});
await test.step("the second call forms an open group around the opened call", async () => {
// Reading above the tail, the row holds still; at the tail the new rows push it up.
await scrollChatAwayFromBottom(page, { deltaY: -160, minDistanceFromBottom: 72 });
const loneTop = await topOf(lone);
const loneRow = await timelineRowOf(lone);
await resetReactRenders(page);
gate.releaseSecondRunning();
await expect(group.getByTestId("tool-call-badge")).toHaveCount(2);
await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible();
expect(await isInsideRow(group, loneRow)).toBe(true);
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2);
expect((await readReactRenders(page)).AgentStreamView).toBeGreaterThan(0);
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
});
await test.step("closing and reopening the group keeps the call open", async () => {
await openToolDetails(group);
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
await expect(details).toHaveCount(0);
await openToolDetails(group);
await expect(group.getByTestId("tool-call-badge")).toHaveCount(2);
await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible();
});
} finally {
await agent.cleanup();
}
});
}
test("forms a closed group when the lone tool call was left closed", async ({ page }) => {
test("keeps overview tool calls inline on desktop", async ({ page }) => {
test.setTimeout(120_000);
const agent = await createOverviewAgent(page, "Closed desktop overview tool calls");
try {
const gate = await gateSecondToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Leave the first tool call closed.");
await gate.waitForFirstCompleted();
const lone = firstToolCall(page);
const group = page.getByTestId("tool-call-group").first();
await expect(lone).toBeVisible();
await expect(group).toHaveCount(0);
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
await gate.waitForSecondRunning();
const loneTop = await topOf(lone);
gate.releaseSecondRunning();
try {
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
await gate.waitForFirstCompleted();
const group = page.getByTestId("tool-call-group").first();
await expect(group).toBeVisible();
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2);
// Keep streaming layout changes out of the pointer-down/pointer-up interval.
const header = group.getByRole("button").first();
await header.click();
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
gate.release(1);
await expect.poll(() => group.getByTestId("tool-call-badge").count()).toBeGreaterThanOrEqual(2);
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
} finally {
await agent.cleanup();
}
@@ -1,9 +1,11 @@
import type { Locator } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { gateSecondToolCall } from "../support/helpers/tool-call-gate";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
type WebSocketMessage = string | Buffer;
interface ShimmerEvidence {
animationDuration: string;
endPx: number;
@@ -12,6 +14,127 @@ interface ShimmerEvidence {
startPx: number;
}
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getToolCallStatus(
message: WebSocketMessage,
agentId: string,
): { callId: string; status: string } | null {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type !== "agent_stream") {
return null;
}
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | undefined;
return event?.type === "timeline" &&
item?.type === "tool_call" &&
typeof item.callId === "string" &&
typeof item.status === "string"
? { callId: item.callId, status: item.status }
: null;
}
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw) as {
message?: { payload?: { event?: { item?: { status?: string } } } };
};
const item = envelope.message?.payload?.event?.item;
if (!item) {
throw new Error("Expected a tool-call session message");
}
item.status = status;
return JSON.stringify(envelope);
}
async function gateSecondToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let secondCallId: string | null = null;
let secondRunningMessage: WebSocketMessage | null = null;
let pauseAfterSecondRunning = false;
let releaseSecondRequested = false;
let forwardSecondRunning: (() => void) | null = null;
let resolveFirstCompleted!: () => void;
let resolveSecondRunning!: () => void;
const firstCompleted = new Promise<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((resolve) => {
resolveSecondRunning = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
forwardSecondRunning = () => {
if (secondRunningMessage) {
ws.send(secondRunningMessage);
secondRunningMessage = null;
}
};
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (pauseAfterSecondRunning) {
return;
}
const toolCall = getToolCallStatus(message, agentId);
if (!toolCall) {
ws.send(message);
return;
}
if (!firstCallId) {
firstCallId = toolCall.callId;
}
if (toolCall.callId === firstCallId) {
ws.send(message);
if (toolCall.status === "completed") {
resolveFirstCompleted();
}
return;
}
secondCallId ??= toolCall.callId;
if (toolCall.callId === secondCallId) {
const transformedMessage =
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
secondRunningMessage = transformedMessage;
pauseAfterSecondRunning = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
ws.send(message);
});
});
return {
waitForFirstCompleted: () => firstCompleted,
waitForSecondRunning: () => secondRunning,
releaseSecondRunning() {
releaseSecondRequested = true;
forwardSecondRunning?.();
},
};
}
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
return locator.evaluate((root) => {
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
+2 -202
View File
@@ -193,7 +193,6 @@ 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();
@@ -203,190 +202,6 @@ test("received messages show their sender and open that agent", async ({ page })
}
});
test("long sender attribution stays on one line in compact chat", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() =>
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })),
);
const recipient = await seedMockAgentWorkspace({
repoPrefix: "compact-attribution-",
title: "Implementation",
});
try {
const title = "Reviewer checking the compact chat timeline and sender attribution";
const sender = await recipient.client.createAgent({
provider: "codex",
cwd: recipient.cwd,
workspaceId: recipient.workspaceId,
title,
model: "gpt-6.1-sol",
modeId: "full-access",
});
await recipient.client.sendAgentMessage(
recipient.agentId,
"The sender label stays on one line, even when the agent has a long name.",
{ sourceAgentId: sender.id },
);
await openAgentRoute(page, recipient);
await expectComposerVisible(page);
const attribution = page.getByRole("button", { name: `Sent by ${title}`, exact: true });
await expect(attribution).toBeVisible();
await expectSenderTruncatedWithinMessage(attribution, title);
await expectSenderAlignedWithBubble(attribution);
await attribution.scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("sender-compact-390.png") });
await page.setViewportSize({ width: 320, height: 740 });
await expectSenderTruncatedWithinMessage(attribution, title);
await expectSenderAlignedWithBubble(attribution);
await page.screenshot({ path: testInfo.outputPath("sender-compact-320.png") });
} finally {
await recipient.cleanup();
}
});
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 expectSenderTruncatedWithinMessage(attribution: Locator, title: string) {
const label = attribution.getByText(`Sent by ${title}`, { exact: true });
await expect(label).toHaveCSS("white-space", "nowrap");
await expect(label).toHaveCSS("text-overflow", "ellipsis");
const bounds = await label.evaluate((element) => ({
textWidth: element.scrollWidth,
visibleWidth: element.clientWidth,
right: element.getBoundingClientRect().right,
viewportWidth: document.documentElement.clientWidth,
}));
expect(bounds.textWidth).toBeGreaterThan(bounds.visibleWidth);
expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth);
}
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-",
@@ -486,7 +301,6 @@ 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);
@@ -525,25 +339,11 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
await expect(reasoning).toHaveCount(0);
const group = page
.getByTestId("tool-call-group")
.filter({ hasText: /read 1 file/i, visible: true })
.filter({ hasText: /thought/, 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();
// Expand thinking is off: the group opens its rows, but the thought row stays closed
// until the reader opens it.
const thought = group
.getByTestId("tool-call-badge")
.filter({ hasText: /^Thinking/ })
.first();
await expect(thought).toBeVisible();
await expect(reasoning).toHaveCount(0);
// A press right after the group opens occasionally does not register; retry until the
// thought row opens.
await expect(async () => {
await thought.getByRole("button").first().click();
await expect(reasoning).toBeVisible({ timeout: 2_000 });
}).toPass();
await expect(reasoning).toBeVisible();
}
}
@@ -6,7 +6,6 @@ for await (const line of lines) {
const command = JSON.parse(line);
let data = {};
if (command.type === "get_available_models") {
if (process.argv.includes("--stall-model-discovery")) continue;
data = {
models: [
{
@@ -1,14 +0,0 @@
# Native user message preview
Open a chat with a user message containing 16 newline-separated lines of
`Preview line N`, with N from 1 to 16. Scroll the message into view on a
phone-sized Android emulator or iOS simulator. Its text must exceed the 240-point preview cap,
while its expanded bubble and toggle fit in the chat viewport. Close any other
expanded messages so the toggle labels identify this message.
Replay `native-user-message-preview.android.ad` or
`native-user-message-preview.ios.ad` through the existing mobile runner or
agent-device. It checks that a clipped message offers Show more and
that two expand/collapse round trips keep both controls reachable. Before the
fix, the first Show more assertion fails because native layout reports the
capped height as the full content height.
@@ -1,14 +0,0 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "role=\"button\" label=\"Show more\"" 10000
press "role=\"button\" label=\"Show more\""
wait "role=\"button\" label=\"Show less\"" 10000
press "role=\"button\" label=\"Show less\""
wait "role=\"button\" label=\"Show more\"" 10000
press "role=\"button\" label=\"Show more\""
wait "role=\"button\" label=\"Show less\"" 10000
press "role=\"button\" label=\"Show less\""
wait "role=\"button\" label=\"Show more\"" 10000
close
@@ -1,14 +0,0 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "role=\"button\" label=\"Show more\"" 10000
press "role=\"button\" label=\"Show more\""
wait "role=\"button\" label=\"Show less\"" 10000
press "role=\"button\" label=\"Show less\""
wait "role=\"button\" label=\"Show more\"" 10000
press "role=\"button\" label=\"Show more\""
wait "role=\"button\" label=\"Show less\"" 10000
press "role=\"button\" label=\"Show less\""
wait "role=\"button\" label=\"Show more\"" 10000
close
@@ -12,22 +12,6 @@ point and Metro workflow in [status-ring](../status-ring/README.md), importing
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
With this fixture open in an Android Agent Device session, run:
```sh
node packages/app/e2e/mobile/markdown-formatting/android.mjs <session>
```
The runner switches to paragraphs with long inline code at a fixed 300 dp width.
It compares the native Markdown host’s bottom with its last TextView’s bottom,
then repeats in compact mode and after restoring regular text. More than one
pixel of surplus fails. Keep the whole fixture visible: clipped accessibility
bounds cannot establish measurement parity.
`scripts/postinstall-patches.test.mjs` regenerates both platforms’ props and
compares them with the shipped headers. This catches missing C++ fields in the
measurement path, which the Kotlin tests cannot exercise.
The package patch contains `InlineCodeFormattingTest`, which exercises actual
Android spans in Robolectric native graphics mode, covering draw and measurement,
span order, code inside paragraph/heading sizes, bold/italic composition, and
@@ -1,60 +0,0 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const session = process.argv[2];
assert(session, "Pass an Android agent-device session serving native.tsx");
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function assertMeasurementParity(mode) {
const snapshot = JSON.parse(ad("snapshot", "--raw", "--json"));
assert.equal(snapshot.success, true);
const nodes = snapshot.data.nodes;
let last = nodes.find((node) => node.label?.endsWith("End of measurement fixture."));
assert(last?.rect, "The last paragraph must be visible, including its bottom");
// EnrichedMarkdownText exposes paragraph accessibility nodes inside its TextView;
// EnrichedMarkdown exposes the TextViews themselves. Measure the native child.
let host = nodes.find((node) => node.index === last.parentIndex);
if (host?.type === "android.widget.TextView") {
last = host;
host = nodes.find((node) => node.index === last.parentIndex);
}
assert.equal(host?.type, "android.widget.FrameLayout");
const viewport = nodes.find((node) => node.type === "android.widget.ScrollView");
const footer = nodes.find((node) => node.label === "End of Markdown host");
assert(viewport?.rect && footer?.rect, "The fixture and footer must be visible");
assert(
host.rect.y >= viewport.rect.y &&
footer.rect.y + footer.rect.height <= viewport.rect.y + viewport.rect.height,
"The whole fixture must fit in the viewport; clipped bounds cannot prove parity",
);
const hostBottom = host.rect.y + host.rect.height;
const childBottom = last.rect.y + last.rect.height;
console.log(
JSON.stringify({
mode,
host: host.rect,
lastChild: last.rect,
surplus: hostBottom - childBottom,
}),
);
assert(
Math.abs(hostBottom - childBottom) <= 1,
`Markdown reserves blank space in ${mode}: host bottom ${hostBottom}, last child bottom ${childBottom}`,
);
}
ad("press", 'label="Show wrapping fixture"');
ad("wait", "text", "End of Markdown host", "10000");
assertMeasurementParity("regular");
ad("press", 'label="Use compact text"');
ad("wait", "text", "Use regular text", "10000");
assertMeasurementParity("compact");
ad("press", 'label="Use regular text"');
ad("wait", "text", "Use compact text", "10000");
assertMeasurementParity("regular restored");
console.log("PASS Android Markdown host measurement matches its last child");
@@ -1,6 +1,6 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { Button, ScrollView, Text, View } from "react-native";
import { Button, ScrollView, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { MarkdownRenderer } from "@/components/markdown/renderer";
@@ -10,7 +10,6 @@ const styles = StyleSheet.create((theme) => ({
scroll: { backgroundColor: theme.colors.surface0 },
content: { padding: 32, paddingTop: 60 },
title: { color: theme.colors.foreground },
wrapping: { width: 300 },
}));
const queryClient = new QueryClient();
@@ -33,15 +32,7 @@ const source = [
"Normal **bold** and *italic* prose.",
].join("\n\n");
const wrappingSource = [
"Measurement parity. Take a repo with a gitignored `.env` in the main checkout and a `.worktreeinclude` containing `.env`.",
"Every creation path goes through `createPaseoWorktreeWorkflow` in `worktree-session.ts:341`, before `createAgent` in `creation/index.ts:160-174` starts.",
"The built-in plugin lives in `plugins/worktree-include/index.server.ts` and copies `paseo.json` beside `.worktreeinclude`. End of measurement fixture.",
].join("\n\n");
export default function MarkdownFormattingRegression() {
const [wrapping, setWrapping] = useState(false);
const toggleWrapping = useCallback(() => setWrapping((value) => !value), []);
const [compact, setCompact] = useState(false);
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
return (
@@ -53,14 +44,7 @@ export default function MarkdownFormattingRegression() {
title={compact ? "Use regular text" : "Use compact text"}
onPress={toggleCompact}
/>
<Button
title={wrapping ? "Show formatting fixture" : "Show wrapping fixture"}
onPress={toggleWrapping}
/>
<View style={wrapping ? styles.wrapping : undefined}>
<MarkdownRenderer text={wrapping ? wrappingSource : source} compact={compact} />
</View>
<Text style={styles.title}>End of Markdown host</Text>
<MarkdownRenderer text={source} compact={compact} />
</ScrollView>
</GestureHandlerRootView>
</QueryClientProvider>
@@ -338,32 +338,11 @@ 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,
options: OlderTimelinePagesOptions = {},
daemonPort?: number,
): Promise<OlderTimelinePagesGate> {
const { daemonPort } = options;
let requestCount = 0;
let responseCount = 0;
let repeatedEntryCount = 0;
@@ -397,8 +376,7 @@ export async function holdAgentOlderTimelinePages(
}
server.send(message);
});
server.onMessage((received) => {
const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received;
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
@@ -1,78 +0,0 @@
import type { Page } from "@playwright/test";
declare global {
var __PASEO_E2E_REACT_RENDERS__: Record<string, number> | undefined;
}
/**
* Counts which components render in each React commit, by name, the way React DevTools
* highlights updates: a fiber rendered when React performed work on it in that commit.
* Install before the app loads; React registers with the DevTools hook at startup.
*/
export async function recordReactRenders(page: Page): Promise<void> {
await page.addInitScript(() => {
const PERFORMED_WORK = 1;
interface Fiber {
type: unknown;
flags: number;
child: Fiber | null;
sibling: Fiber | null;
alternate: Fiber | null;
}
const nameOf = (type: unknown): string | null => {
if (typeof type === "function") {
return (type as { displayName?: string }).displayName || type.name || null;
}
if (!type || typeof type !== "object") return null;
const component = type as { displayName?: string; render?: unknown; type?: unknown };
return component.displayName || nameOf(component.render) || nameOf(component.type);
};
const record = (root: Fiber) => {
const renders = (globalThis.__PASEO_E2E_REACT_RENDERS__ ??= {});
const pending: Fiber[] = [root];
while (pending.length > 0) {
const fiber = pending.pop()!;
const name = typeof fiber.type === "string" ? null : nameOf(fiber.type);
if (name && (fiber.flags & PERFORMED_WORK) !== 0) renders[name] = (renders[name] ?? 0) + 1;
// A subtree React bailed out of is shared with the previous commit; skip it.
if (fiber.alternate && fiber.child === fiber.alternate.child) continue;
for (let child = fiber.child; child; child = child.sibling) pending.push(child);
}
};
let rendererCount = 0;
Object.defineProperty(globalThis, "__REACT_DEVTOOLS_GLOBAL_HOOK__", {
configurable: true,
value: {
supportsFiber: true,
isDisabled: false,
renderers: new Map(),
inject(renderer: unknown) {
rendererCount += 1;
this.renderers.set(rendererCount, renderer);
return rendererCount;
},
checkDCE() {},
onScheduleFiberRoot() {},
onCommitFiberRoot(_rendererId: number, root: { current: Fiber }) {
record(root.current);
},
onPostCommitFiberRoot() {},
onCommitFiberUnmount() {},
},
});
});
}
export async function resetReactRenders(page: Page): Promise<void> {
await page.evaluate(() => {
globalThis.__PASEO_E2E_REACT_RENDERS__ = {};
});
}
/** Render counts per component name since the last reset, read after the next painted frame. */
export async function readReactRenders(page: Page): Promise<Record<string, number>> {
return page.evaluate(async () => {
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return { ...globalThis.__PASEO_E2E_REACT_RENDERS__ };
});
}
+1 -16
View File
@@ -5,7 +5,7 @@ export function sheet(page: Page, label: string): Locator {
return page.getByRole("dialog", { name: label });
}
/** A sheet tab's header, which carries its title, Back, Close, and the Open as tab glyph. */
/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */
export function sheetHeader(page: Page, label: string): Locator {
return page.getByTestId("sheet-tab-header").filter({ hasText: label });
}
@@ -48,12 +48,6 @@ export async function closeSheet(page: Page, label: string): Promise<void> {
await expect(sheet(page, label)).toHaveCount(0);
}
/** Steps a sheet opened from a list back to that list, in the same sheet. */
export async function backToList(page: Page, label: string): Promise<void> {
await sheetHeader(page, label).getByRole("button", { name: "Back" }).click();
await expect(sheet(page, label)).toHaveCount(0);
}
export async function openSheetAsTab(page: Page, label: string): Promise<void> {
await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
await expect(sheet(page, label)).toHaveCount(0);
@@ -82,15 +76,6 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
/** Each open sheet renders its own backdrop, so counting them counts the sheets. */
export async function expectOneSheetOpen(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
}
export async function expectNoSheetOpen(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
export async function tapSheetBackdrop(page: Page): Promise<void> {
await page
+5 -16
View File
@@ -3,7 +3,7 @@ import { startIsolatedHostDaemon } from "./isolated-host-daemon";
import { seedWorkspace } from "./seed-client";
import { getE2EDaemonPort } from "./daemon-port";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Locator, type Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import type { SeededWorkspace } from "./seed-client";
@@ -151,21 +151,10 @@ export async function openSubagentsTrack(page: Page): Promise<void> {
await expect(panel).toBeVisible({ timeout: 30_000 });
}
/**
* On compact the track opens as a sheet, and the chosen row opens in its place. `track` is the
* pane whose track to open, for when a subagent's sheet shows a track of its own.
*/
export async function openSubagentFromCompactTrack(
page: Page,
childId: string,
track: Page | Locator = page,
): Promise<void> {
await track.getByTestId("subagents-track-header").click();
const row = page.getByTestId(`subagents-track-row-${childId}`);
// Tap the row once the sheet has brought it on screen, as a finger would. Clicking it while it is
// still below the fold makes Playwright scroll the sheet host, and every sheet then docks short.
await expect(row).toBeInViewport();
await row.click();
/** On compact the track opens as a sheet, which closes once a row is chosen. */
export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise<void> {
await page.getByTestId("subagents-track-header").click();
await page.getByTestId(`subagents-track-row-${childId}`).click();
}
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
@@ -1,4 +1,4 @@
import { expect, type Locator, type TestInfo, type Page } from "@playwright/test";
import { expect, type TestInfo, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { readReplicaCache } from "./replica-cache-storage";
import {
@@ -9,7 +9,6 @@ import {
type AgentTimelineResponseGate,
type BootstrapTimelineGate,
type OlderTimelinePagesGate,
type OlderTimelinePagesOptions,
} from "./agent-timeline-gate";
export { holdDaemonHydration };
@@ -36,19 +35,6 @@ 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;
@@ -140,93 +126,6 @@ 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,
@@ -417,14 +316,13 @@ export async function holdNextOlderTimelinePage(
export async function holdOlderHistoryPages(
page: Page,
agent: MockAgentWorkspace,
options?: OlderTimelinePagesOptions,
daemonPort?: number,
): Promise<OlderHistoryPages> {
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
page,
agent.agentId,
options,
daemonPort,
);
const loader = page.getByTestId("load-older-history-spinner");
return {
async expectRequestedPages(count) {
await gate.waitForRequestCount(count);
@@ -432,17 +330,7 @@ export async function holdOlderHistoryPages(
},
async expectSettledWithRequestedPages(count) {
await waitForTimelineGeometryToSettle(page);
// 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 });
expect(gate.getRequestCount()).toBe(count);
},
expectNoRepeatedEntries() {
expect(gate.getRepeatedEntryCount()).toBe(0);
@@ -535,53 +423,6 @@ 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,125 +0,0 @@
import type { Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getToolCallStatus(
message: WebSocketMessage,
agentId: string,
): { callId: string; status: string } | null {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type !== "agent_stream") {
return null;
}
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | undefined;
return event?.type === "timeline" &&
item?.type === "tool_call" &&
typeof item.callId === "string" &&
typeof item.status === "string"
? { callId: item.callId, status: item.status }
: null;
}
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw) as {
message?: { payload?: { event?: { item?: { status?: string } } } };
};
const item = envelope.message?.payload?.event?.item;
if (!item) {
throw new Error("Expected a tool-call session message");
}
item.status = status;
return JSON.stringify(envelope);
}
export async function gateSecondToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let secondCallId: string | null = null;
let secondRunningMessage: WebSocketMessage | null = null;
let pauseAfterSecondRunning = false;
let releaseSecondRequested = false;
let forwardSecondRunning: (() => void) | null = null;
let resolveFirstCompleted!: () => void;
let resolveSecondRunning!: () => void;
const firstCompleted = new Promise<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((resolve) => {
resolveSecondRunning = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
forwardSecondRunning = () => {
if (secondRunningMessage) {
ws.send(secondRunningMessage);
secondRunningMessage = null;
}
};
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (pauseAfterSecondRunning) {
return;
}
const toolCall = getToolCallStatus(message, agentId);
if (!toolCall) {
ws.send(message);
return;
}
if (!firstCallId) {
firstCallId = toolCall.callId;
}
if (toolCall.callId === firstCallId) {
ws.send(message);
if (toolCall.status === "completed") {
resolveFirstCompleted();
}
return;
}
secondCallId ??= toolCall.callId;
if (toolCall.callId === secondCallId) {
const transformedMessage =
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
secondRunningMessage = transformedMessage;
pauseAfterSecondRunning = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
ws.send(message);
});
});
return {
waitForFirstCompleted: () => firstCompleted,
waitForSecondRunning: () => secondRunning,
releaseSecondRunning() {
releaseSecondRequested = true;
forwardSecondRunning?.();
},
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.14.0-beta.2",
"version": "0.13.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -15,7 +15,6 @@ 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: {},
@@ -610,54 +609,8 @@ 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");
@@ -702,28 +655,6 @@ it("does not reopen the previous turn while a new run waits for its first row",
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
// the completed one; it must stay folded until the new turn's rows arrive.
it("keeps the previous turn folded while a sent prompt waits for its turn to open", () => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
for (const item of tail) item.turnId = "completed";
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: true,
activeTurnId: null,
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
const present = createStreamPresentation();
const timestamp = new Date();
+18 -61
View File
@@ -11,7 +11,6 @@ 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[];
@@ -27,22 +26,6 @@ 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({
@@ -78,7 +61,6 @@ 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;
@@ -107,22 +89,20 @@ export function createStreamPresentation() {
function collapse(
projection: ToolCallDetailProjection,
input: PresentationInput,
): Omit<StreamPresentation, "readingRowReplacements"> {
): StreamPresentation {
if (input.display !== "collapseTurns") {
return { ...projection, turns: NO_COLLAPSED_TURNS };
}
// Live text updates reach the head only, so the folded history is reused until the
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
const last = projection.tail.at(-1) ?? null;
const first = projection.head[0] ?? null;
const activeTurnIsAnother = Boolean(
input.activeTurnId && last?.turnId && input.activeTurnId !== last.turnId,
);
// Without head rows, the latest retained turn is live only while the active run is that
// turn. A run that has not opened yet carries no id and belongs to the next turn.
const isLatestTurnOpen = first
? !isTurnBoundary(last, first) && !activeTurnIsAnother
: input.isTurnActive && (last?.turnId === undefined || input.activeTurnId === last.turnId);
const isLatestTurnOpen =
(input.isTurnActive || projection.head.length > 0) &&
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
!(
input.activeTurnId &&
projection.tail.at(-1)?.turnId &&
input.activeTurnId !== projection.tail.at(-1)?.turnId
);
if (
!collapsed ||
collapsedSource !== projection.tail ||
@@ -177,42 +157,19 @@ 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;
}
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,
return collapse(
projectToolCallDetailLevel({
level,
tail: historyRows,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
}),
input,
);
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,24 +2,6 @@ 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,10 +6,6 @@ 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.
@@ -18,15 +14,10 @@ export function createReadingAnchor() {
let geometry: readonly RowGeometry[] = [];
const readingRow = (scrollTop: number) =>
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
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);
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) =>
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0);
return {
getRowId,
getRowId: () => anchor?.id ?? null,
getReadingRowId: (scrollTop: number) =>
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
project,
@@ -37,18 +28,13 @@ export function createReadingAnchor() {
const next = readingRow(scrollTop);
anchor = next ? { id: next.id, top: next.top } : null;
},
reconcile(
scrollTop: number,
rows: readonly RowGeometry[],
userScrolled = false,
replacements = NO_REPLACEMENTS,
): number {
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number {
geometry = rows;
const previous = anchor && rows.find((row) => row.id === getRowId(replacements));
const previous = anchor && rows.find((row) => row.id === anchor?.id);
// 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, replacements);
const correctedTop = project(scrollTop, previous ?? undefined);
// 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);
+7 -16
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(props.readingRowReplacements),
(row) => row.id === readingAnchor.getRowId(),
);
const container = scrollContainerRef.current;
// TanStack has computed the new measurements before calling the extractor.
@@ -404,14 +404,10 @@ 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,
},
props.readingRowReplacements,
)
? readingAnchor.project(container.scrollTop, {
id: String(measurement.key),
top: measurement.start,
})
: container?.scrollTop;
let visibleRange = range;
if (
@@ -432,7 +428,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]),
].sort((left, right) => left - right);
},
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements],
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized],
);
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
@@ -746,12 +742,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
if (!shouldAnchor) readingAnchor.reset();
const correctedTop = readingAnchor.reconcile(
container.scrollTop,
rows,
false,
props.readingRowReplacements,
);
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows);
if (!shouldAnchor) {
readingAnchor.reset();
return;
@@ -1,7 +1,6 @@
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 {
@@ -79,8 +78,6 @@ 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,14 +48,6 @@ 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,8 +25,6 @@ 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 {
@@ -82,7 +80,6 @@ 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) => {
@@ -117,7 +114,6 @@ 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))),
@@ -143,7 +139,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
turnsByHostId: EMPTY_TURNS,
nestedItemIds: EMPTY_IDS,
hostIdByFoldedMessageId: EMPTY_HOSTS,
hostIdByHiddenRowId: EMPTY_HOSTS,
};
}
return {
@@ -154,6 +149,5 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
turnsByHostId,
nestedItemIds,
hostIdByFoldedMessageId,
hostIdByHiddenRowId,
};
}
+55 -65
View File
@@ -61,11 +61,6 @@ import { returnToTimelineTail } from "./timeline-tail-navigation";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
import {
ToolCallExpansionContext,
useAgentToolCallExpansion,
useExpandedToolCallGroups,
} from "@/tool-calls/expansion-context";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { createStreamPresentation } from "./presentation";
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
@@ -95,7 +90,6 @@ 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,
@@ -369,8 +363,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
new Set(),
);
const toolCallExpansion = useAgentToolCallExpansion(agentId);
const expandedToolCallGroups = useExpandedToolCallGroups(toolCallExpansion);
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
new Set(),
);
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
// Get serverId (fallback to agent's serverId if not provided)
@@ -435,6 +430,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
useEffect(() => {
setIsNearBottom(true);
setExpandedInlineToolCallIds(new Set());
setExpandedToolCallGroupIds(new Set());
setExpandedTurnHostIds(new Set());
}, [agentId]);
@@ -463,7 +459,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
view: "files",
});
});
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
const inlinePathOpener = useMemo(
() =>
createInlinePathOpener({
@@ -471,9 +466,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client,
openFile: openInlineFile,
openDirectory: openInlineDirectory,
openOutsideDirectory: openInlineOutsideDirectory,
}),
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
[workspaceRoot, client, openInlineFile, openInlineDirectory],
);
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
@@ -711,7 +705,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
});
const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded;
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
setExpandedToolCallGroupIds((previous) => {
const next = new Set(previous);
if (expanded) {
next.add(groupId);
} else {
next.delete(groupId);
}
return next;
});
}, []);
const renderUserMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
@@ -808,7 +812,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
(
item: Extract<StreamItem, { kind: "tool_call" }>,
isLastInSequence: boolean,
options: { maxDetailHeight?: number; standalone?: boolean } = {},
maxDetailHeight?: number,
) => {
const { payload } = item;
@@ -840,8 +844,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentMessage={data.agentMessage}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
standalone={options.standalone}
maxDetailHeight={options.maxDetailHeight}
maxDetailHeight={maxDetailHeight}
/>
);
}
@@ -857,8 +860,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
status={data.status}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
standalone={options.standalone}
maxDetailHeight={options.maxDetailHeight}
maxDetailHeight={maxDetailHeight}
/>
);
},
@@ -881,7 +883,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
? renderThoughtItem(layoutItem, item)
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
}
const expanded = expandedToolCallGroups.has(group.run.id);
const expanded = expandedToolCallGroupIds.has(group.run.id);
return (
<OverviewToolCallGroupView
group={group}
@@ -893,22 +895,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
? group.run.calls.map((call, index) => (
<React.Fragment key={call.id}>
{call.kind === "thought" ? (
// Reasoning is grouped only while Expand thinking is off, so an opened
// group shows each thought as a closed row like any other call.
<ThoughtSlot
itemId={call.id}
text={call.text}
status={call.status}
isLastInSequence={index === group.run.calls.length - 1}
defaultExpanded={false}
standalone={false}
defaultExpanded
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
/>
) : (
renderSingleToolCallItem(call, index === group.run.calls.length - 1, {
maxDetailHeight: GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
standalone: false,
})
renderSingleToolCallItem(
call,
index === group.run.calls.length - 1,
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
)
)}
</React.Fragment>
))
@@ -917,7 +917,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
},
[
expandedToolCallGroups,
expandedToolCallGroupIds,
renderThoughtItem,
setInlineDetailsExpanded,
getToolCallGroup,
@@ -1169,18 +1169,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
() => ({
contentById: presentation.historyGroupUpdatesByHostId,
displayStateById: {
// An opened lone call also marks its future group open; that row stays unrevised
// until a group actually forms there.
has: (id: string) =>
(expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) ||
expandedToolCallGroupIds.has(id) ||
expandedTurnHostIds.has(id) ||
nestedItemIds.has(id),
},
globalDisplayState: isMobile,
}),
[
expandedToolCallGroups,
getToolCallGroup,
expandedToolCallGroupIds,
expandedTurnHostIds,
isMobile,
nestedItemIds,
@@ -1205,32 +1202,29 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<ToolCallSheetProvider>
<AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
<ToolCallExpansionContext.Provider value={toolCallExpansion}>
{streamRenderStrategy.render({
readingRowReplacements: presentation.readingRowReplacements,
agentId,
segments: renderModel.segments,
historyRowRevision,
liveHeadRowRevision: expandedToolCallGroups,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: setIsNearBottom,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
})}
</ToolCallExpansionContext.Provider>
{streamRenderStrategy.render({
agentId,
segments: renderModel.segments,
historyRowRevision,
liveHeadRowRevision: expandedToolCallGroupIds,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: setIsNearBottom,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
})}
</MessageOuterSpacingProvider>
<ChatOutlineRail
prompts={chatOutline.prompts}
@@ -1403,7 +1397,6 @@ interface ThoughtSlotProps {
status: Extract<StreamItem, { kind: "thought" }>["status"];
isLastInSequence: boolean;
defaultExpanded: boolean;
standalone?: boolean;
}
// Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text.
@@ -1414,7 +1407,6 @@ function ThoughtSlot({
status,
isLastInSequence,
defaultExpanded,
standalone,
}: ThoughtSlotProps) {
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
return (
@@ -1426,7 +1418,6 @@ function ThoughtSlot({
status={status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
defaultExpanded={defaultExpanded}
standalone={standalone}
forceInline={defaultExpanded}
/>
);
@@ -1441,9 +1432,7 @@ function ToolCallSlot({
(expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded),
[onInlineDetailsExpandedChangeByItemId, itemId],
);
return (
<ToolCall {...rest} itemId={itemId} onInlineDetailsExpandedChange={handleExpandedChange} />
);
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
@@ -1906,11 +1895,12 @@ 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}
{turnIndent === "openedHost" ? <IndentRail style={gapStyle} /> : null}
<IndentRail style={gapStyle} />
</View>
);
case "nested":
@@ -71,9 +71,6 @@ 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");
@@ -98,9 +95,6 @@ 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();
@@ -108,25 +102,3 @@ 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,23 +14,12 @@ interface PathOpenInput {
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
}
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("~/");
}
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
if (!input.file) {
return isOutsideWorkspacePath(input.directory)
? { kind: "outsideDirectory", directoryPath: input.directory }
: { kind: "directory", directoryPath: input.directory };
return { kind: "directory", directoryPath: input.directory };
}
if (
input.lineStart !== undefined ||
@@ -53,7 +42,6 @@ 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 {
@@ -70,8 +58,6 @@ 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,
@@ -1,33 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { Text } from "react-native";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SheetBottomInset } from "./adaptive-modal-sheet";
beforeEach(() => vi.stubGlobal("React", React));
const SCREEN_INSETS = { top: 24, right: 0, bottom: 34, left: 0 };
function BottomInsetProbe() {
return <Text testID="content-bottom-inset">{String(useSafeAreaInsets().bottom)}</Text>;
}
describe("SheetBottomInset", () => {
it("pads the bottom inset once and leaves none for its content", () => {
render(
<SafeAreaInsetsContext.Provider value={SCREEN_INSETS}>
<SheetBottomInset testID="sheet-body">
<BottomInsetProbe />
</SheetBottomInset>
</SafeAreaInsetsContext.Provider>,
);
expect(screen.getByTestId("sheet-body").style.paddingBottom).toBe("34px");
// Content that pads the inset itself, like the composer dock, must not pad it again.
expect(screen.getByTestId("content-bottom-inset").textContent).toBe("0");
});
});
@@ -3,7 +3,7 @@ import type { ComponentProps, ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Keyboard, Pressable, Text, View } from "react-native";
import type { DimensionValue, StyleProp, ViewProps, ViewStyle } from "react-native";
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
@@ -12,12 +12,7 @@ import {
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "../lib/overlay-root";
import {
BottomSheetHandle,
useBottomSheetGestureHandlers,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native";
import {
@@ -30,7 +25,7 @@ import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-shee
import { ScrollView } from "@/components/ui/scroll-view";
import { isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AdaptiveTextInput } from "@/components/adaptive-text-input";
export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input";
@@ -45,9 +40,6 @@ export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
// glyph should line up with the X must reach the same rail.
export const SHEET_HEADER_CLOSE_PADDING_SCALE = 2;
/** The one height a sheet takes when it shows a whole pane rather than sizing to its content. */
export const FULL_SHEET_SNAP_POINTS = ["92%"];
export interface SheetHeaderSearch {
onChange: (value: string) => void;
onFocus?: () => void;
@@ -252,55 +244,32 @@ const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({
indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] },
}));
/** The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. */
const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 };
/**
* The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children
* join the handle, so a header passed here drags the sheet like the indicator does. That is how a
* sheet whose content scrolls on its own keeps its header draggable without content panning.
*/
export function SheetHandle({
children,
// Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator.
indicatorStyle: _indicatorStyle,
...props
}: ComponentProps<typeof BottomSheetHandle>) {
return <ThemedBottomSheetHandle {...props} />;
}
/**
* Content that drags the sheet the way its handle does. A sheet whose body scrolls on its own
* turns content panning off and wraps its header in this, so the header still pulls it down.
* Render it inside the sheet's content.
*/
export function SheetDragArea({ children }: { children: ReactNode }) {
const { handlePanGestureHandler } = useBottomSheetGestureHandlers();
const gesture = useMemo(
() =>
Gesture.Pan()
.shouldCancelWhenOutside(false)
.runOnJS(false)
.onStart(handlePanGestureHandler.handleOnStart)
.onChange(handlePanGestureHandler.handleOnChange)
.onEnd(handlePanGestureHandler.handleOnEnd)
.onFinalize(handlePanGestureHandler.handleOnFinalize),
[handlePanGestureHandler],
);
if (!children) return <ThemedBottomSheetHandle {...props} />;
// The children carry their own semantics; the handle's slider role would swallow them on web.
return (
<GestureDetector gesture={gesture}>
<View>{children}</View>
</GestureDetector>
);
}
/**
* A compact sheet's content area that reaches the bottom of the screen. It owns the system
* navigation inset: it pads it once and tells its content none is left, so content that pads the
* inset itself, like the composer dock, sits the same in a sheet as in a tab.
*/
export function SheetBottomInset({ style, children, ...props }: ViewProps) {
const insets = useSafeAreaInsets();
const contentInsets = useMemo(() => ({ ...insets, bottom: 0 }), [insets]);
const insetStyle = useMemo(() => [style, { paddingBottom: insets.bottom }], [style, insets]);
return (
<View {...props} style={insetStyle}>
<SafeAreaInsetsContext.Provider value={contentInsets}>
{children}
</SafeAreaInsetsContext.Provider>
</View>
<ThemedBottomSheetHandle
{...props}
style={HANDLE_WITH_CONTENT_STYLE}
accessible={false}
accessibilityRole={null}
accessibilityLabel={null}
accessibilityHint={null}
>
{children}
</ThemedBottomSheetHandle>
);
}
@@ -519,76 +488,6 @@ export interface AdaptiveModalSheetProps {
contextBridge?: ContextBridge | null;
}
/**
* The compact sheet's body. It renders on the far side of the sheet's portal so it reads the
* screen's bottom inset, not the one left where the sheet was opened: inside a sheet tab, none is.
*/
function CompactSheetBody({
header,
onClose,
testID,
footer,
isKeyboardVisible,
scrollable,
bodyStyle,
contentStyle,
children,
}: {
header: SheetHeader;
onClose: () => void;
testID?: string;
footer: ReactNode;
isKeyboardVisible: boolean;
scrollable: boolean;
bodyStyle?: StyleProp<ViewStyle>;
contentStyle?: StyleProp<ViewStyle>;
children: ReactNode;
}) {
const insets = useSafeAreaInsets();
const { contentPaddingBottom = 0, footerPaddingBottom = 0 } = getCompactSheetSafeAreaPadding({
isCompact: true,
isKeyboardVisible,
hasFooter: Boolean(footer),
safeAreaBottom: insets.bottom,
});
// Safe-area clearance is a separate layer: it must not replace the caller's
// padding (including an explicit zero), and the footer owns it when present.
const bodyClearanceStyle = { paddingBottom: contentPaddingBottom };
const footerClearanceStyle = useMemo(
() => ({ paddingBottom: footerPaddingBottom }),
[footerPaddingBottom],
);
const footerView = useMemo(
() => (footer ? <View style={footerClearanceStyle}>{footer}</View> : null),
[footer, footerClearanceStyle],
);
return (
<SheetVisibleFrame footer={footerView}>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
<View style={[styles.compactStaticContent, bodyStyle]}>
{scrollable ? (
<ScrollView
style={styles.bottomSheetVisibleScroll}
contentContainerStyle={SCROLL_CONTENT_GROW}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={[styles.contentGrow, bodyClearanceStyle]}>
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
</View>
</ScrollView>
) : (
<View style={[styles.compactStaticContent, bodyClearanceStyle]}>
<SheetContent style={[styles.compactStaticContent, contentStyle]}>
{children}
</SheetContent>
</View>
)}
</View>
</SheetVisibleFrame>
);
}
export function AdaptiveModalSheet({
header,
visible,
@@ -609,11 +508,34 @@ export function AdaptiveModalSheet({
}: AdaptiveModalSheetProps) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const isKeyboardVisible = useKeyboardVisibility(visible);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const compactSafeAreaPadding = useMemo(
() =>
getCompactSheetSafeAreaPadding({
isCompact: isMobile,
isKeyboardVisible,
hasFooter: Boolean(footer),
safeAreaBottom: insets.bottom,
}),
[footer, insets.bottom, isKeyboardVisible, isMobile],
);
// Safe-area clearance is a separate layer: it must not replace the caller's
// padding (including an explicit zero), and the footer owns it when present.
const bodyClearanceStyle = { paddingBottom: compactSafeAreaPadding.contentPaddingBottom ?? 0 };
const footerClearanceStyle = useMemo(
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
[compactSafeAreaPadding.footerPaddingBottom],
);
const footerView = useMemo(
() => (footer ? <View style={[styles.footer, footerContainerStyle]}>{footer}</View> : null),
[footer, footerContainerStyle],
() =>
footer ? (
<View style={footerClearanceStyle}>
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
</View>
) : null,
[footer, footerClearanceStyle, footerContainerStyle],
);
useEffect(() => {
if (!isWeb && visible) {
@@ -692,6 +614,32 @@ export function AdaptiveModalSheet({
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
if (isMobile) {
const sheetContent = (
<>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
<View style={[styles.compactStaticContent, bodyStyle]}>
{scrollable ? (
<ScrollView
style={styles.bottomSheetVisibleScroll}
contentContainerStyle={SCROLL_CONTENT_GROW}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={[styles.contentGrow, bodyClearanceStyle]}>
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
</View>
</ScrollView>
) : (
<View style={[styles.compactStaticContent, bodyClearanceStyle]}>
<SheetContent style={[styles.compactStaticContent, contentStyle]}>
{children}
</SheetContent>
</View>
)}
</View>
</>
);
return (
<IsolatedBottomSheetModal
ref={sheetRef}
@@ -710,18 +658,7 @@ export function AdaptiveModalSheet({
accessible={false}
presentation={presentation}
>
<CompactSheetBody
header={header}
onClose={onClose}
testID={testID}
footer={footerView}
isKeyboardVisible={isKeyboardVisible}
scrollable={scrollable}
bodyStyle={bodyStyle}
contentStyle={contentStyle}
>
{children}
</CompactSheetBody>
<SheetVisibleFrame footer={footerView}>{sheetContent}</SheetVisibleFrame>
</IsolatedBottomSheetModal>
);
}
@@ -72,7 +72,6 @@ vi.mock("react-i18next", () => ({
}));
vi.mock("react-native-safe-area-context", () => ({
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
@@ -1,8 +1,8 @@
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { AttachmentMetadata } from "@/attachments/types";
@@ -28,6 +28,7 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const metadata = source?.type === "attachment" ? source.metadata : null;
const attachmentUrl = useAttachmentPreviewUrl(metadata);
const url = source?.type === "uri" ? source.uri : attachmentUrl;
@@ -55,6 +56,18 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
onKeyDown: handleWebOverlayKeyDown,
});
const contentLayerStyle = useMemo(
() => [
styles.contentLayer,
{
paddingTop: insets.top,
paddingRight: insets.right,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
},
],
[insets.bottom, insets.left, insets.right, insets.top],
);
const actions = useMemo(
() => [
{
@@ -78,19 +91,17 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<ModalRoot style={styles.root}>
{/* The modal is its own window: its insets are the window's, not the ones left where the
image was opened (inside a sheet tab, the bottom one is already spent). */}
<SafeAreaProvider>
<WindowChromeRootRegion corners="both">
<View ref={setWebOverlayScope} style={styles.root}>
<Pressable
testID="attachment-lightbox-backdrop"
accessibilityRole="button"
accessibilityLabel={t("message.attachments.dismissImage")}
onPress={onClose}
style={styles.backdrop}
/>
<LightboxImageArea>
<WindowChromeRootRegion corners="both">
<View ref={setWebOverlayScope} style={styles.root}>
<Pressable
testID="attachment-lightbox-backdrop"
accessibilityRole="button"
accessibilityLabel={t("message.attachments.dismissImage")}
onPress={onClose}
style={styles.backdrop}
/>
<View pointerEvents="box-none" style={contentLayerStyle}>
<View pointerEvents="box-none" style={styles.imageArea}>
{hasError ? (
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
) : (
@@ -106,38 +117,15 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
uri={url}
/>
)}
</LightboxImageArea>
</View>
</View>
</WindowChromeRootRegion>
</SafeAreaProvider>
</View>
</WindowChromeRootRegion>
</ModalRoot>
</Modal>
);
}
function LightboxImageArea({ children }: { children: ReactNode }) {
const insets = useSafeAreaInsets();
const style = useMemo(
() => [
styles.contentLayer,
{
paddingTop: insets.top,
paddingRight: insets.right,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
},
],
[insets.bottom, insets.left, insets.right, insets.top],
);
return (
<View pointerEvents="box-none" style={style}>
<View pointerEvents="box-none" style={styles.imageArea}>
{children}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
+2 -15
View File
@@ -27,7 +27,6 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { useToolCallExpanded } from "@/tool-calls/expansion-context";
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
@@ -2242,9 +2241,6 @@ interface ToolCallProps {
defaultExpanded?: boolean;
forceInline?: boolean;
maxDetailHeight?: number;
itemId: string;
/** Shown as its own row rather than inside a tool call group. */
standalone?: boolean;
}
export const ToolCall = memo(function ToolCall({
@@ -2266,15 +2262,9 @@ export const ToolCall = memo(function ToolCall({
defaultExpanded,
forceInline = false,
maxDetailHeight = 400,
itemId,
standalone = true,
}: ToolCallProps) {
const { openToolCall } = useToolCallSheet();
const { expanded: isExpanded, toggle: toggleExpanded } = useToolCallExpanded({
callId: itemId,
defaultExpanded: defaultExpanded ?? false,
standalone,
});
const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false);
const isMobile = useIsCompactFormFactor();
const shouldRenderInline = !isMobile || forceInline;
@@ -2329,11 +2319,10 @@ export const ToolCall = memo(function ToolCall({
showLoadingSkeleton: presentation.isLoadingDetails,
});
} else {
toggleExpanded();
setIsExpanded((prev) => !prev);
}
}, [
shouldRenderInline,
toggleExpanded,
openToolCall,
toolName,
presentation.detail,
@@ -2439,7 +2428,5 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
if (previous.defaultExpanded !== next.defaultExpanded) return false;
if (previous.forceInline !== next.forceInline) return false;
if (previous.maxDetailHeight !== next.maxDetailHeight) return false;
if (previous.itemId !== next.itemId) return false;
if (previous.standalone !== next.standalone) return false;
return true;
}
@@ -1692,7 +1692,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0,
},
emptyState: {
padding: theme.spacing[4],
paddingVertical: theme.spacing[4],
alignItems: "center",
gap: theme.spacing[2],
},
+1 -7
View File
@@ -168,7 +168,6 @@ export function Button({
trailing,
style,
textStyle,
numberOfLines,
disabled,
loading = false,
accessibilityRole,
@@ -183,7 +182,6 @@ export function Button({
trailing?: ReactNode;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
numberOfLines?: number;
loading?: boolean;
}
>) {
@@ -279,11 +277,7 @@ export function Button({
iconSize={buttonIconSize[size]}
uniProps={resolveIconMapping()}
/>
{children != null ? (
<Text numberOfLines={numberOfLines} style={resolvedTextStyle}>
{children}
</Text>
) : null}
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
{trailing}
</Pressable>
);
+59 -64
View File
@@ -938,6 +938,7 @@ interface MobileBodyProps {
handleSelect: (id: string) => void;
renderOption: RenderOptionFn | undefined;
children: ReactNode;
safeAreaBottom: number;
}
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
@@ -960,6 +961,21 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
);
const { footer, safeAreaBottom } = props;
const frameStyle = useMemo(
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
[footer, safeAreaBottom],
);
const footerStyle = useMemo(
() => [styles.footer, { paddingBottom: safeAreaBottom }],
[safeAreaBottom],
);
const footerView = useMemo(
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
[footer, footerStyle],
);
const body = props.hasChildren ? (
props.children
) : (
@@ -990,74 +1006,51 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
keyboardBlurBehavior="none"
presentation={props.presentation}
>
<ComboboxSheetFrame footer={props.footer}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
</ComboboxSheetFrame>
<SheetVisibleFrame footer={footerView}>
<View style={frameStyle}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
</View>
</SheetVisibleFrame>
</IsolatedBottomSheetModal>
);
}
/**
* Rendered on the far side of the sheet's portal so it clears the screen's bottom inset, not the
* one left where the combobox was opened: inside a sheet tab, none is.
*/
function ComboboxSheetFrame({ footer, children }: { footer?: ReactNode; children: ReactNode }) {
const { bottom: safeAreaBottom } = useSafeAreaInsets();
const frameStyle = useMemo(
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
[footer, safeAreaBottom],
);
const footerStyle = useMemo(
() => [styles.footer, { paddingBottom: safeAreaBottom }],
[safeAreaBottom],
);
const footerView = useMemo(
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
[footer, footerStyle],
);
return (
<SheetVisibleFrame footer={footerView}>
<View style={frameStyle}>{children}</View>
</SheetVisibleFrame>
);
}
interface DesktopBodyProps {
overlayLayer: number;
isOpen: boolean;
@@ -1334,6 +1327,7 @@ export function Combobox({
const isMobile = useIsCompactFormFactor();
const active = useRetainedPanelActive();
const floatingLayer = useOverlayLayer("floating");
const safeAreaInsets = useSafeAreaInsets();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition);
@@ -1614,6 +1608,7 @@ export function Combobox({
emptyText={resolvedEmptyText}
handleSelect={handleSelect}
renderOption={renderOption}
safeAreaBottom={safeAreaInsets.bottom}
>
{children}
</MobileComboboxBody>
@@ -123,25 +123,19 @@ export function useIsolatedBottomSheetVisibility({
visible,
isEnabled,
onClose,
onBack,
}: {
visible: boolean;
isEnabled?: boolean;
onClose: () => void;
/** Offered Android Back before it closes the sheet; returns true when it handled the press. */
onBack?: () => boolean;
}) {
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const onBackRef = useRef(onBack);
onBackRef.current = onBack;
const tracker = useMemo(
() =>
createBottomSheetVisibilityTracker({
onClose: () => onCloseRef.current(),
backPress: systemBackPress,
onBack: () => onBackRef.current?.() ?? false,
}),
[],
);
@@ -204,31 +204,6 @@ describe("bottom sheet visibility tracker", () => {
expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
});
it("lets the sheet step back through its own pages before Back dismisses it", () => {
const sheet = new FakeBottomSheet();
const backPress = new FakeBackPress();
let openPages = 1;
const tracker = createBottomSheetVisibilityTracker({
onClose: () => {},
onBack: () => {
if (openPages === 0) return false;
openPages -= 1;
return true;
},
backPress,
});
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
tracker.handleSheetIndexChange(0);
expect(backPress.press()).toBe(true);
expect(openPages).toBe(0);
expect(sheet.events).toEqual([{ type: "present" }]);
expect(backPress.press()).toBe(true);
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
});
it("leaves Back to the navigator before the sheet is on screen and after it closes", () => {
const context = setup();
context.tracker.attachController(context.sheet);
@@ -36,11 +36,6 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
export function createBottomSheetVisibilityTracker(opts: {
onClose: () => void;
backPress?: BackPressSource;
/**
* Offered the Back press before the sheet takes it as a close. Returns true when it handled
* the press itself, such as stepping back a page inside the sheet.
*/
onBack?: () => boolean;
}): BottomSheetVisibilityTracker {
let controller: BottomSheetController | null = null;
let visible = false;
@@ -65,7 +60,6 @@ export function createBottomSheetVisibilityTracker(opts: {
}
releaseBackPress =
opts.backPress?.subscribe(() => {
if (opts.onBack?.()) return true;
dismiss();
return true;
}) ?? null;
@@ -12,8 +12,6 @@ export { MenuRoot, MenuTrigger, type MenuTriggerProps, type MenuTriggerState } f
export {
MenuSurface,
useMenuSurface,
type MenuFillPageDefinition,
type MenuListPageDefinition,
type MenuPageDefinition,
type MenuSurfaceProps,
} from "./menu-surface";
@@ -61,38 +61,3 @@ describe("useMenuState selectItem elsewhere", () => {
expect(onSelect).toHaveBeenCalledTimes(1);
});
});
describe("useMenuState pages", () => {
it("pops the open page on Back and leaves Back at the root to the surface", () => {
const { result } = renderHook(() => useMenuState({ defaultOpen: true }));
act(() => result.current.openSub({ id: "row", depth: 0 }));
let handled = false;
act(() => {
handled = result.current.goBack();
});
expect(handled).toBe(true);
expect(result.current.path).toEqual([]);
act(() => {
handled = result.current.goBack();
});
expect(handled).toBe(false);
expect(result.current.open).toBe(true);
});
it("falls back to the list when the open page's definition goes away", () => {
const { result } = renderHook(() => useMenuState({ defaultOpen: true }));
act(() => result.current.openSub({ id: "row", depth: 0 }));
const keepNoPage = () => false;
act(() => result.current.prunePages(keepNoPage));
expect(result.current.path).toEqual([]);
let handled = true;
act(() => {
handled = result.current.goBack();
});
expect(handled).toBe(false);
});
});
@@ -7,7 +7,6 @@ import {
goBack as goBackPath,
MENU_ROOT_PATH,
openSubPage,
pruneMissingPages,
type MenuPath,
} from "./menu-navigation";
import type { Rect } from "../anchor";
@@ -41,10 +40,7 @@ export interface MenuContextValue {
path: MenuPath;
openSub: (sub: { id: string; depth: number }) => void;
closeSub: (depth: number) => void;
/** Pops the open page. False at the root, where Back is the surface's to answer. */
goBack: () => boolean;
/** Closes any open page that no longer has a definition, falling back to its parent. */
prunePages: (hasPage: (id: string) => boolean) => void;
goBack: () => void;
}
const MenuContext = createContext<MenuContextValue | null>(null);
@@ -164,13 +160,7 @@ export function useMenuState({
}, []);
const goBack = useCallback(() => {
if (path.length === 0) return false;
setPath((current) => goBackPath(current));
return true;
}, [path.length]);
const prunePages = useCallback((hasPage: (id: string) => boolean) => {
setPath((current) => pruneMissingPages(current, hasPage));
}, []);
return useMemo(
@@ -186,19 +176,7 @@ export function useMenuState({
openSub,
closeSub,
goBack,
prunePages,
}),
[
isOpen,
setOpen,
selectItem,
anchorRect,
presentation,
path,
openSub,
closeSub,
goBack,
prunePages,
],
[isOpen, setOpen, selectItem, anchorRect, presentation, path, openSub, closeSub, goBack],
);
}
@@ -6,7 +6,6 @@ import {
isSubPageOpen,
MENU_ROOT_PATH,
openSubPage,
pruneMissingPages,
} from "./menu-navigation";
describe("openSubPage", () => {
@@ -74,15 +73,3 @@ describe("isSubPageOpen", () => {
expect(isSubPageOpen(path, { id: "grouping", depth: 0 })).toBe(false);
});
});
describe("pruneMissingPages", () => {
it("falls back to the deepest page that still exists", () => {
expect(pruneMissingPages(["list", "row"], (id) => id === "list")).toEqual(["list"]);
expect(pruneMissingPages(["list", "row"], (id) => id === "row")).toEqual([]);
});
it("returns the same path when every page exists", () => {
const path = ["list", "row"];
expect(pruneMissingPages(path, () => true)).toBe(path);
});
});
@@ -33,16 +33,6 @@ export function closeSubPage(path: MenuPath, depth: number): MenuPath {
return path.slice(0, depth);
}
/**
* Cuts the path back to the deepest page that still has a definition. A page can lose its
* definition while it is open (a subagent's page goes with its row when the subagent is
* archived), and the pages it opened go with it.
*/
export function pruneMissingPages(path: MenuPath, hasPage: (id: string) => boolean): MenuPath {
const missing = path.findIndex((id) => !hasPage(id));
return missing === -1 ? path : path.slice(0, missing);
}
export function goBack(path: MenuPath): MenuPath {
if (path.length === 0) return path;
return path.slice(0, -1);
@@ -19,11 +19,6 @@ import {
useIsolatedBottomSheetVisibility,
type ContextBridge,
} from "@/components/ui/isolated-bottom-sheet-modal";
import {
FULL_SHEET_SNAP_POINTS,
SheetBackground,
SheetHandle,
} from "@/components/adaptive-modal-sheet";
import { SPACING, type Theme } from "@/styles/theme";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useMenuContext, MenuContextProvider } from "./menu-context";
@@ -37,6 +32,24 @@ import type { KeyboardFocusScope } from "@/keyboard/actions";
const ThemedChevronLeft = withUnistyles(ChevronLeft);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not
// track, so the sheet is wrapped rather than reading the theme through a hook.
// See docs/unistyles.md.
const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({
backgroundStyle: {
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
borderWidth: 1,
backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
handleIndicatorStyle: {
width: 36,
height: 4,
backgroundColor: theme.colors.surface2,
},
}));
/** How long the pointer must rest on a submenu row before its flyout opens. */
const HOVER_OPEN_DELAY_MS = 90;
/**
@@ -52,7 +65,7 @@ const HOVER_CLOSE_GRACE_MS = 260;
*/
const SUBMENU_OVERLAP = 5;
export interface MenuListPageDefinition {
export interface MenuPageDefinition {
id: string;
title: string;
content: ReactNode;
@@ -65,24 +78,6 @@ export interface MenuListPageDefinition {
* surface stops closing on hover, since its parent leads back to the same dismissal.
*/
hoverIntent?: boolean;
fill?: false;
}
/**
* A sheet page that takes the sheet's full height and draws everything itself, header included:
* no back header, no scroller, no content panning. Only the sheet presentation opens one; a
* popover never has a trigger for it.
*/
export interface MenuFillPageDefinition {
id: string;
fill: true;
content: ReactNode;
}
export type MenuPageDefinition = MenuListPageDefinition | MenuFillPageDefinition;
function pageHoverIntent(page: MenuPageDefinition | undefined): boolean {
return !page || page.fill === true || page.hoverIntent !== false;
}
interface MenuSurfaceContextValue {
@@ -131,15 +126,8 @@ export interface MenuSurfaceProps {
* popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time.
*/
export function MenuSurface(props: MenuSurfaceProps): ReactElement | null {
const { presentation, open, setOpen, path, prunePages } = useMenuContext("MenuSurface");
const { presentation, open, setOpen } = useMenuContext("MenuSurface");
const active = useRetainedPanelActive();
// An open page whose definition went away (its row was removed) falls back to its parent, so
// neither a render nor a later Back walks a path to a page that no longer exists.
const { pages } = props;
useEffect(() => {
if (path.length === 0) return;
prunePages((id) => pages?.some((page) => page.id === id) ?? false);
}, [pages, path, prunePages]);
// A menu belongs to the panel that opened it. When that panel leaves the screen the menu closes
// for good, rather than waiting hidden to reappear with the panel: a row that opened a sheet
// and promoted it to a tab, say, would otherwise bring the list back on the next tab switch.
@@ -246,11 +234,11 @@ function MenuPopoverSurface({
// `hoverIntent: false` takes a page off the pointer entirely — it is not opened by resting on
// its trigger, and while it is open nothing on this surface closes on a pointer leaving it.
const hoverValue = useMemo<MenuSurfaceContextValue>(() => {
const locked = openPages.some(({ page }) => !pageHoverIntent(page));
const locked = openPages.some(({ page }) => page.hoverIntent === false);
return {
...surfaceValue,
hoverOpen: (sub) => {
if (!pageHoverIntent(pages.find((page) => page.id === sub.id))) return;
if (pages.find((page) => page.id === sub.id)?.hoverIntent === false) return;
surfaceValue.hoverOpen(sub);
},
hoverClose: (depth) => {
@@ -365,6 +353,17 @@ function MenuSheetSurface({
const { height: windowHeight } = useWindowDimensions();
const menu = useMenuContext("MenuSurface");
const { value: surfaceValue } = useSubAnchors();
const safeAreaInsets = useSafeAreaInsets();
const sheetScrollContentStyle = useMemo(
() => ({
paddingBottom: getMenuSheetBottomPadding({
safeAreaBottom: safeAreaInsets.bottom,
breathingRoom: SPACING[1],
}),
}),
[safeAreaInsets.bottom],
);
const sheetDataSet = useMemo(
() => (keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : undefined),
[keyboardFocusScope],
@@ -375,15 +374,11 @@ function MenuSheetSurface({
visible: menu.open,
isEnabled: true,
onClose: handleClose,
// Android Back steps back through the pages before it closes the sheet.
onBack: menu.goBack,
});
const openPageId = currentPageId(menu.path);
const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null;
const depth = menu.path.length;
const fillPage = openPage?.fill === true ? openPage : null;
const listPage = openPage?.fill === true ? null : openPage;
// The sheet's content is teleported out of this subtree, so both menu contexts have to be
// rebuilt on the other side. Providing them around the modal instead would put them on the
@@ -398,18 +393,12 @@ function MenuSheetSurface({
);
return (
<IsolatedBottomSheetModal
<ThemedBottomSheetModal
ref={sheetRef}
contextBridge={contextBridge}
backgroundComponent={SheetBackground}
handleComponent={SheetHandle}
// Content-sized rather than fixed snap points: a pushed page is rarely the same height
// as the page it replaced, and a fixed sheet would either clip it or leave dead space.
// A fill page is the exception: it takes the height every full sheet has, and its own
// content scrolls, so dragging it would fight that scroll.
enableDynamicSizing={!fillPage}
snapPoints={fillPage ? FULL_SHEET_SNAP_POINTS : undefined}
enableContentPanningGesture={!fillPage}
enableDynamicSizing
maxDynamicContentSize={windowHeight * 0.8}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
@@ -423,64 +412,28 @@ function MenuSheetSurface({
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
>
{fillPage ? (
<View style={styles.fillPage} testID={testID ? `${testID}-${fillPage.id}` : undefined}>
{fillPage.content}
</View>
) : (
<MenuSheetScrollView dataSet={sheetDataSet} testID={testID}>
{listPage ? (
<>
<MenuSheetHeader title={listPage.title} onBack={menu.goBack} />
<MenuPage depth={depth}>{listPage.content}</MenuPage>
</>
) : (
<>
{sheetTitle ? (
<MenuSheetHeader title={sheetTitle} onBack={null} trailing={sheetTrailing} />
) : null}
<MenuPage depth={0}>{children}</MenuPage>
</>
)}
</MenuSheetScrollView>
)}
</IsolatedBottomSheetModal>
);
}
/**
* Rendered on the far side of the sheet's portal so it clears the screen's bottom inset, not the
* one left where the menu was opened: inside a sheet tab, none is.
*/
function MenuSheetScrollView({
dataSet,
testID,
children,
}: {
dataSet: { keyboardScope: KeyboardFocusScope } | undefined;
testID?: string;
children: ReactNode;
}) {
const safeAreaInsets = useSafeAreaInsets();
const contentStyle = useMemo(
() => ({
paddingBottom: getMenuSheetBottomPadding({
safeAreaBottom: safeAreaInsets.bottom,
breathingRoom: SPACING[1],
}),
}),
[safeAreaInsets.bottom],
);
return (
<BottomSheetScrollView
dataSet={dataSet}
contentContainerStyle={contentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
testID={testID ? `${testID}-content` : undefined}
>
{children}
</BottomSheetScrollView>
<BottomSheetScrollView
dataSet={sheetDataSet}
contentContainerStyle={sheetScrollContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
testID={testID ? `${testID}-content` : undefined}
>
{openPage ? (
<>
<MenuSheetHeader title={openPage.title} onBack={menu.goBack} />
<MenuPage depth={depth}>{openPage.content}</MenuPage>
</>
) : (
<>
{sheetTitle ? (
<MenuSheetHeader title={sheetTitle} onBack={null} trailing={sheetTrailing} />
) : null}
<MenuPage depth={0}>{children}</MenuPage>
</>
)}
</BottomSheetScrollView>
</ThemedBottomSheetModal>
);
}
@@ -517,10 +470,6 @@ function MenuSheetHeader({
}
const styles = StyleSheet.create((theme) => ({
fillPage: {
flex: 1,
minHeight: 0,
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
@@ -1,5 +1,5 @@
import { useState, useCallback, useMemo, type ReactNode } from "react";
import { View, type LayoutChangeEvent, type ViewStyle } from "react-native";
import { View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
@@ -8,29 +8,23 @@ import { EdgeFade } from "@/components/ui/edge-fade";
import { useProviderIcon } from "@/components/provider-icons";
import { useSessionStore } from "@/stores/session-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { isNative } from "@/constants/platform";
const PREVIEW_HEIGHT = 240;
// Native View still clips with "scroll", but Yoga measures its children without
// the height cap. "hidden" caps the measured body too, hiding the expand control.
const previewStyle: ViewStyle = {
maxHeight: PREVIEW_HEIGHT,
overflow: isNative ? "scroll" : "hidden",
};
export function MessagePreview({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const [overflowing, setOverflowing] = useState(false);
const [height, setHeight] = useState(0);
const overflowing = height > PREVIEW_HEIGHT;
const measure = useCallback(
(event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT),
(event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height),
[],
);
const toggle = useCallback(() => setExpanded((value) => !value), []);
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
return (
<>
<View style={!expanded && previewStyle}>
<View style={!expanded && styles.preview}>
<View style={styles.body} onLayout={measure}>
{children}
</View>
@@ -42,7 +36,7 @@ export function MessagePreview({ children }: { children: ReactNode }) {
<Button
variant="ghost"
size="sm"
style={[styles.toggle, expanded && styles.expandedToggle]}
style={styles.toggle}
accessibilityState={accessibilityState}
onPress={toggle}
>
@@ -79,9 +73,6 @@ export function MessageSender({
variant="ghost"
size="sm"
leftIcon={renderProviderIcon}
style={styles.sender}
textStyle={styles.senderText}
numberOfLines={1}
disabled={!host}
onPress={openSender}
>
@@ -91,10 +82,8 @@ export function MessageSender({
}
const styles = StyleSheet.create((theme) => ({
preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" },
body: { flexShrink: 0 },
sender: { paddingRight: 0, borderRightWidth: 0, maxWidth: "100%" },
senderText: { flexShrink: 1 },
toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 },
expandedToggle: { marginTop: theme.spacing[2] },
fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] },
}));
@@ -41,7 +41,7 @@ import {
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { UserMessageImageAttachment } from "@/types/stream";
import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import {
buildWorkspaceTabPersistenceKey,
@@ -390,7 +390,7 @@ export function WorkspaceDraftAgentTab({
const editingSession = useComposerEditingSession("chat");
const isComposerFullscreen = useComposerFullscreen(editingSession);
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_CONTENT_WIDTH,
COMPACT_FORM_FACTOR_WIDTH,
{ initialIsBelow: isCompactFormFactor },
);
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
+6 -22
View File
@@ -8,8 +8,6 @@ import {
MenuSurface,
MenuTrigger,
useMenuContext,
useMenuDepth,
type MenuPageDefinition,
type MenuTriggerState,
} from "@/components/ui/menu";
import { StatusRing } from "@/components/status-ring";
@@ -63,8 +61,6 @@ export interface ComposerTrackPillProps {
accessibilityLabel?: string;
/** Panel body. Rendered into a popover on wide screens and a sheet on compact ones. */
children: ReactNode;
/** Pages a row can open in place of the list (`ComposerTrackRow` `opensPage`). */
pages?: readonly MenuPageDefinition[];
}
/**
@@ -89,7 +85,6 @@ export function ComposerTrackPill({
testID,
accessibilityLabel,
children,
pages,
}: ComposerTrackPillProps): ReactElement {
return (
<MenuRoot compactMode="sheet">
@@ -103,7 +98,6 @@ export function ComposerTrackPill({
align="start"
offset={PANEL_OFFSET}
sheetTitle={panelTitle}
pages={pages}
minWidth={PANEL_MIN_WIDTH}
maxWidth={PANEL_MAX_WIDTH}
maxHeight={PANEL_MAX_HEIGHT}
@@ -205,11 +199,6 @@ export interface ComposerTrackRowProps {
* lands inside the panel is not.
*/
closeOnSelect?: boolean;
/**
* A page of the panel this row opens in place of the list, instead of running `onPress`. The
* panel stays open: Back returns to the list.
*/
opensPage?: string;
disabled?: boolean;
accessibilityLabel?: string;
testID?: string;
@@ -231,23 +220,18 @@ export function ComposerTrackRow({
children,
onPress,
closeOnSelect = true,
opensPage,
disabled = false,
accessibilityLabel,
testID,
}: ComposerTrackRowProps): ReactElement {
const { selectItem, openSub } = useMenuContext("ComposerTrackRow");
const depth = useMenuDepth();
const { selectItem } = useMenuContext("ComposerTrackRow");
const [hovered, setHovered] = useState(false);
const handlePointerEnter = useCallback(() => setHovered(true), []);
const handlePointerLeave = useCallback(() => setHovered(false), []);
const handleSelect = useCallback(() => {
if (opensPage) {
selectItem(() => openSub({ id: opensPage, depth }), false);
return;
}
selectItem(onPress, closeOnSelect);
}, [closeOnSelect, depth, onPress, openSub, opensPage, selectItem]);
const handleSelect = useCallback(
() => selectItem(onPress, closeOnSelect),
[closeOnSelect, onPress, selectItem],
);
const itemDataSet = useMemo(
() => ({ menuItem: "true", menuDisabled: disabled ? "true" : "false" }),
@@ -267,7 +251,7 @@ export function ComposerTrackRow({
[hovered, renderRow],
);
if (!onPress && !opensPage) {
if (!onPress) {
return <View accessibilityLabel={accessibilityLabel}>{renderRow(false)}</View>;
}
+4 -5
View File
@@ -11,11 +11,10 @@ export const HEADER_INNER_HEIGHT_MOBILE = 56;
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
export const HEADER_TOP_PADDING_MOBILE = 8;
// Measured composer width; independent of the window's compact/wide breakpoint.
export const COMPACT_CONTENT_WIDTH = 500;
export const COMPACT_FORM_FACTOR_WIDTH = 500;
// Settings navigation can shrink below its preferred width to preserve the detail
// minimum. Both targets must fit before Settings shares width with app navigation.
// Settings uses the canonical desktop list + detail layout. Its sidebar and
// detail target must fit together before it can share width with app navigation.
export const SETTINGS_DESKTOP_SIDEBAR_WIDTH = 320;
export const SETTINGS_DESKTOP_DETAIL_MIN_WIDTH = 400;
export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH =
@@ -34,7 +33,7 @@ export {
getIsElectronMac as getIsElectronRuntimeMac,
} from "./platform";
/** Below md (600 points), sidebars overlay the workspace; wider layouts can dock them. */
/** Below 600 points, sidebars overlay the workspace. Medium and wider layouts dock them. */
export function isCompactBreakpoint(breakpoint: string | undefined): boolean {
return breakpoint === "xs" || breakpoint === "sm";
}
+1 -1
View File
@@ -67,7 +67,6 @@ export const ar: TranslationResources = {
daemonClientUnavailable: "عميل Daemon غير متوفر",
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
},
connectionStatus: {
@@ -1914,6 +1913,7 @@ export const ar: TranslationResources = {
parent: {
backToParent: "العودة إلى الوكيل الأصلي",
backTo: "العودة إلى {{label}}",
parent: "الأصل",
parentAgent: "الوكيل الأصلي",
openParent: "فتح الوكيل الأصلي",
},
+1 -1
View File
@@ -63,7 +63,6 @@ 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: {
@@ -1939,6 +1938,7 @@ export const en = {
parent: {
backToParent: "Back to parent",
backTo: "Back to {{label}}",
parent: "Parent",
parentAgent: "Parent agent",
openParent: "Open parent",
},
+1 -1
View File
@@ -68,7 +68,6 @@ 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: {
@@ -1962,6 +1961,7 @@ export const es: TranslationResources = {
parent: {
backToParent: "Volver al padre",
backTo: "Volver a {{label}}",
parent: "Padre",
parentAgent: "Agente padre",
openParent: "Abrir padre",
},
+1 -1
View File
@@ -68,7 +68,6 @@ 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: {
@@ -1960,6 +1959,7 @@ export const fr: TranslationResources = {
parent: {
backToParent: "Retour au parent",
backTo: "Retour à {{label}}",
parent: "Parent",
parentAgent: "Agent parent",
openParent: "Ouvrir le parent",
},
+1 -1
View File
@@ -68,7 +68,6 @@ export const ja: TranslationResources = {
daemonClientUnavailable: "デーモンクライアントが利用できません",
daemonClientDisconnected: "デーモンクライアントが切断されています",
noFileFound: "{{token}}のファイルが見つかりません",
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
},
connectionStatus: {
@@ -1933,6 +1932,7 @@ export const ja: TranslationResources = {
parent: {
backToParent: "親に戻る",
backTo: "{{label}} に戻る",
parent: "親",
parentAgent: "親エージェント",
openParent: "親を開く",
},
+1 -1
View File
@@ -67,7 +67,6 @@ export const ko: TranslationResources = {
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
},
connectionStatus: {
@@ -1924,6 +1923,7 @@ export const ko: TranslationResources = {
parent: {
backToParent: "상위 에이전트로 돌아가기",
backTo: "{{label}}(으)로 돌아가기",
parent: "상위",
parentAgent: "상위 에이전트",
openParent: "상위 에이전트 열기",
},
+1 -1
View File
@@ -68,7 +68,6 @@ 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: {
@@ -1947,6 +1946,7 @@ export const ptBR: TranslationResources = {
parent: {
backToParent: "Voltar ao pai",
backTo: "Voltar para {{label}}",
parent: "Pai",
parentAgent: "Agente pai",
openParent: "Abrir pai",
},
+1 -1
View File
@@ -68,7 +68,6 @@ export const ru: TranslationResources = {
daemonClientUnavailable: "Daemon клиента недоступен",
daemonClientDisconnected: "Daemon клиента отключен",
noFileFound: "Файл для {{token}} не найден",
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
},
connectionStatus: {
@@ -1946,6 +1945,7 @@ export const ru: TranslationResources = {
parent: {
backToParent: "Назад к родителю",
backTo: "Назад к {{label}}",
parent: "Родитель",
parentAgent: "Родительский агент",
openParent: "Открыть родителя",
},
+1 -1
View File
@@ -67,7 +67,6 @@ export const zhCN: TranslationResources = {
daemonClientUnavailable: "Daemon client 不可用",
daemonClientDisconnected: "Daemon client 已断开连接",
noFileFound: "未找到 {{token}} 对应的文件",
folderOutsideWorkspace: "{{path}} 不在此工作区内",
unexpectedDictationError: "处理听写时发生意外错误。",
},
connectionStatus: {
@@ -1892,6 +1891,7 @@ export const zhCN: TranslationResources = {
parent: {
backToParent: "返回父代理",
backTo: "返回 {{label}}",
parent: "父代理",
parentAgent: "父代理",
openParent: "打开父代理",
},
+2 -2
View File
@@ -44,7 +44,7 @@ import { useAgentDescriptor } from "@/agent-presentation";
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
@@ -1512,7 +1512,7 @@ function ActiveAgentComposer({
}) {
const isCompactFormFactor = useIsCompactFormFactor();
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_CONTENT_WIDTH,
COMPACT_FORM_FACTOR_WIDTH,
{ initialIsBelow: isCompactFormFactor },
);
const paneContext = usePaneContext();
+3 -3
View File
@@ -13,7 +13,7 @@ import {
type ArchiveFinishedStatus,
useArchiveSubagent,
useDetachSubagent,
useSubagentSheetTab,
useSubagentOpensInSheet,
type SubagentRow,
} from "@/subagents";
import { ParentAgentPill } from "@/subagents/parent";
@@ -100,7 +100,7 @@ export const AgentTracks = memo(function AgentTracks({
},
[openSubagentTarget],
);
const sheetTabFor = useSubagentSheetTab(workspaceKey, tabId);
const opensInSheet = useSubagentOpensInSheet(workspaceKey);
const handleOpenChanges = useCallback(() => {
if (!workspaceKey) {
return;
@@ -134,7 +134,7 @@ export const AgentTracks = memo(function AgentTracks({
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
sheetTabFor={sheetTabFor}
opensInSheet={opensInSheet}
onArchiveSubagent={archiveSubagent}
onArchiveFinished={onArchiveFinished}
archiveFinishedStatus={archiveFinishedStatus}
@@ -16,10 +16,9 @@ import { usePaneContext } from "@/panels/pane-context";
import { PaneSubtitle } from "@/panels/pane-subtitle";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import type { SheetTab } from "@/stores/workspace-layout-store";
import { ParentAgentCallout } from "@/subagents/parent";
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
import { useSubagentSheetTab } from "@/subagents/sheet-tab";
import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet";
import { SubagentsTrack } from "@/subagents/track";
import {
providerSubagentKey,
@@ -52,12 +51,12 @@ function ProviderSubagentChildTrack({
serverId,
rows,
onOpenProviderSubagent,
sheetTabFor,
opensInSheet,
}: {
serverId: string;
rows: ReturnType<typeof useSubagentsForParent>;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
sheetTabFor: (row: SubagentRow) => SheetTab | null;
opensInSheet: (row: SubagentRow) => boolean;
}) {
if (rows.length === 0) return null;
return (
@@ -67,7 +66,7 @@ function ProviderSubagentChildTrack({
rows={rows}
onOpenSubagent={NOOP_SUBAGENT}
onOpenProviderSubagent={onOpenProviderSubagent}
sheetTabFor={sheetTabFor}
opensInSheet={opensInSheet}
onArchiveSubagent={NOOP_SUBAGENT}
/>
</ComposerTrackBar>
@@ -117,7 +116,7 @@ function useProviderSubagentDescriptor(
function ProviderSubagentPanel() {
const { t } = useTranslation();
const { serverId, workspaceId, tabId, target, openFileInWorkspace, openTab } = usePaneContext();
const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext();
invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target");
const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId);
const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`;
@@ -145,9 +144,8 @@ function ProviderSubagentPanel() {
providerParentSubagentId: target.subagentId,
});
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
const sheetTabFor = useSubagentSheetTab(
const opensInSheet = useSubagentOpensInSheet(
buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
tabId,
);
const openProviderChild = useCallback(
(parentAgentId: string, subagentId: string) => {
@@ -276,7 +274,7 @@ function ProviderSubagentPanel() {
serverId={serverId}
rows={childRows}
onOpenProviderSubagent={openProviderChild}
sheetTabFor={sheetTabFor}
opensInSheet={opensInSheet}
/>
</View>
<ParentAgentCallout serverId={serverId} parentAgentId={target.parentAgentId} />
@@ -34,24 +34,11 @@ import {
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
import { render as renderEnvironment } from "@testing-library/react";
import {
SheetPaneHostProvider,
useSheetPaneBridge,
type SheetPaneHost,
} from "@/screens/workspace/sheet-pane";
import { useWorkspaceSheetEnvironment } from "@/screens/workspace/workspace-sheet-environment";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useAssistantFileLinkActions, type InlinePathTarget } from "@/assistant-file-links";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const SHEET_PANE_HOST: SheetPaneHost = {
workspaceKey: "host:workspace",
serverId: "host",
workspaceId: "workspace",
isWorkspaceFocused: true,
buildContent: () => {
throw new Error("The environment probe builds no pane content");
},
};
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
@@ -168,21 +155,14 @@ describe("workspace sheet environment", () => {
}, [name]);
return null;
}
function CaptureBridge() {
bridge = useSheetPaneBridge();
function Capture() {
bridge = useWorkspaceSheetEnvironment();
return (
<ComposerEditingScope tabId="file" targetKey="file:a">
<Probe name="pane" />
</ComposerEditingScope>
);
}
function Capture() {
return (
<SheetPaneHostProvider value={SHEET_PANE_HOST}>
<CaptureBridge />
</SheetPaneHostProvider>
);
}
function Source({ workspaceRoot, active }: { workspaceRoot: string; active: boolean }) {
const onOpenFile = useCallback(
(target: InlinePathTarget) => {
+1 -7
View File
@@ -122,11 +122,7 @@ import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-p
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import {
SETTINGS_DESKTOP_DETAIL_MIN_WIDTH,
SETTINGS_DESKTOP_SIDEBAR_WIDTH,
useIsCompactFormFactor,
} from "@/constants/layout";
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { densityIconBox, densityIconSize } from "@/styles/density";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
@@ -1548,14 +1544,12 @@ const desktopStyles = StyleSheet.create({
},
contentPane: {
flex: 1,
minWidth: SETTINGS_DESKTOP_DETAIL_MIN_WIDTH,
},
});
const sidebarStyles = StyleSheet.create((theme) => ({
desktopContainer: {
width: SETTINGS_DESKTOP_SIDEBAR_WIDTH,
flexShrink: 1,
borderRightWidth: 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
@@ -1,84 +0,0 @@
import React, { createContext, useCallback, useContext, type ReactNode } from "react";
import invariant from "tiny-invariant";
import { NavigationContext, NavigationRouteContext } from "expo-router/react-navigation";
import { useAssistantFileLinkEnvironmentBridge } from "@/assistant-file-links";
import { useComposerEditingEnvironmentBridge } from "@/composer/editing-session";
import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel";
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
import { useDiffDocumentWorkspaceCacheBridge } from "@/git/diff-document/workspace-cache";
import { HostRouteProvider, useHostRouteServerId } from "@/navigation/host-route-context";
import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
/** What a sheet pane needs from the workspace it shows a tab of. */
export interface SheetPaneHost {
workspaceKey: string;
serverId: string;
workspaceId: string;
isWorkspaceFocused: boolean;
/** The pane content for a sheet's tab. Closing the tab from inside it runs `closeCurrentTab`. */
buildContent: (
tab: WorkspaceTabDescriptor,
closeCurrentTab: () => void,
) => WorkspacePaneContentModel;
}
const SheetPaneHostContext = createContext<SheetPaneHost | null>(null);
/** `null` while there is no workspace to show sheets for. */
export function SheetPaneHostProvider({
value,
children,
}: {
value: SheetPaneHost | null;
children: ReactNode;
}) {
return <SheetPaneHostContext.Provider value={value}>{children}</SheetPaneHostContext.Provider>;
}
/** The host of the workspace the caller is in, or null while there is none. */
export function useSheetPaneHostIfAny(): SheetPaneHost | null {
return useContext(SheetPaneHostContext);
}
export function useSheetPaneHost(): SheetPaneHost {
const host = useSheetPaneHostIfAny();
invariant(host, "SheetPaneHostProvider is required");
return host;
}
/**
* Carries a sheet pane's environment across the portal into the app overlay host: the workspace
* host, and the environment of the built-in panels. Pane identity is supplied by the pane itself.
*/
export function useSheetPaneBridge(): ContextBridge {
const host = useSheetPaneHost();
const editing = useComposerEditingEnvironmentBridge();
const cache = useDiffDocumentWorkspaceCacheBridge();
const files = useAssistantFileLinkEnvironmentBridge();
const active = useRetainedPanelActive();
const serverId = useHostRouteServerId();
const navigation = useContext(NavigationContext);
const route = useContext(NavigationRouteContext);
return useCallback(
(children) => {
const content = editing(cache(files(children)));
return (
<SheetPaneHostContext.Provider value={host}>
<NavigationContext.Provider value={navigation}>
<NavigationRouteContext.Provider value={route}>
<RetainedPanelActivity active={active}>
{serverId ? (
<HostRouteProvider serverId={serverId}>{content}</HostRouteProvider>
) : (
content
)}
</RetainedPanelActivity>
</NavigationRouteContext.Provider>
</NavigationContext.Provider>
</SheetPaneHostContext.Provider>
);
},
[host, editing, cache, files, active, serverId, navigation, route],
);
}
@@ -1,16 +0,0 @@
/**
* A workspace tab shown in a sheet. One pane — header, body, bottom inset, focus — behind every
* sheet container: the workspace's sheet tabs, and pages of a menu that shows a sheet in place.
*
* See docs/explorer-sidebar.md.
*/
export {
SheetPaneHostProvider,
useSheetPaneBridge,
useSheetPaneHost,
useSheetPaneHostIfAny,
type SheetPaneHost,
} from "./host";
export { SheetPane, useLeaveSheet, type SheetPaneProps } from "./sheet-pane";
export { useSheetPanePage } from "./page";
@@ -1,38 +0,0 @@
import { useCallback } from "react";
import { useMenuContext, type MenuFillPageDefinition } from "@/components/ui/menu";
import { usePaneContext } from "@/panels/pane-context";
import type { SheetTab } from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { useSheetPaneBridge } from "./host";
import { SheetPane } from "./sheet-pane";
/**
* Shows sheet tabs as pages of a menu opened from a pane, so a row's sheet replaces the list it
* was chosen from instead of stacking a second sheet over it. Back returns to the list; Close
* closes the menu, which uncovers the pane the menu was opened from.
*/
export function useSheetPanePage(): (sheet: SheetTab) => MenuFillPageDefinition {
const bridge = useSheetPaneBridge();
const { target: opener } = usePaneContext();
return useCallback(
(sheet) => ({
id: sheet.tab.tabId,
fill: true,
content: bridge(
// Keyed like the detached container, so the panel's descriptor hook stays fixed per page.
<MenuSheetPane
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
sheet={sheet}
opener={opener}
/>,
),
}),
[bridge, opener],
);
}
function MenuSheetPane({ sheet, opener }: { sheet: SheetTab; opener: WorkspaceTabTarget }) {
const { setOpen, goBack } = useMenuContext("MenuSheetPane");
const close = useCallback(() => setOpen(false), [setOpen]);
return <SheetPane sheet={sheet} onClose={close} onBack={goBack} opener={opener} isTop />;
}
@@ -1,207 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
type ComponentType,
} from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Maximize2 } from "lucide-react-native";
import {
SheetBottomInset,
SheetDragArea,
SheetHeaderIconButton,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import type { PanelIconProps } from "@/panels/panel-registry";
import { getPanelRegistration } from "@/panels/panel-registry";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content";
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { useSheetPaneHost } from "./host";
function PanelIcon({
icon: Icon,
size,
color,
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
return <Icon size={size} color={color} />;
}
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
color: theme.colors.foregroundMuted,
}));
export interface SheetPaneProps {
sheet: SheetTab;
/** Closes the sheet, everything it was chosen from included. */
onClose: () => void;
/** Steps back to the list the sheet was chosen from, when there is one. */
onBack?: () => void;
/** The pane closing the sheet uncovers, when the container knows it. */
opener?: WorkspaceTabTarget | null;
/** Only the top sheet's pane is focused; a covered one stays as live as the pane under it. */
isTop: boolean;
}
interface SheetPaneExit {
close: () => void;
opener: WorkspaceTabTarget | null;
}
const SheetPaneExitContext = createContext<SheetPaneExit | null>(null);
/**
* A tab shown in a sheet: its header (icon, title, Back, Open as tab, Close), which drags the
* sheet, over its pane. Every sheet container renders this; only the container differs.
*/
export function SheetPane({ sheet, onClose, onBack, opener = null, isTop }: SheetPaneProps) {
const { t } = useTranslation();
const host = useSheetPaneHost();
const { tab } = sheet;
const registration = getPanelRegistration(tab.target.kind);
if (!registration) {
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
}
const descriptor = registration.useDescriptor(tab.target as never, {
serverId: host.serverId,
workspaceId: host.workspaceId,
tabId: tab.tabId,
});
const openTabInFront = useWorkspaceLayoutStore((state) => state.openTabInFront);
const { workspaceKey } = host;
const handleOpenAsTab = useCallback(
() => openTabInFront(workspaceKey, sheet),
[openTabInFront, sheet, workspaceKey],
);
const header = useMemo<SheetHeader>(
() => ({
title: descriptor.label,
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
back: onBack ? { onPress: onBack } : undefined,
// The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into
// the switcher, where it gets its composer back.
actions: sheet.policy.openAsTab ? (
<SheetHeaderIconButton
icon={Maximize2}
accessibilityLabel={t("workspace.sheet.openAsTab")}
onPress={handleOpenAsTab}
testID="sheet-tab-open-as-tab"
/>
) : undefined,
}),
[descriptor.icon, descriptor.label, handleOpenAsTab, onBack, sheet.policy.openAsTab, t],
);
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
() => ({
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
state: tab.state,
}),
[tab],
);
const { buildContent } = host;
const content = useMemo(
() => buildContent(tabDescriptor, onClose),
[buildContent, onClose, tabDescriptor],
);
const exit = useMemo<SheetPaneExit>(() => ({ close: onClose, opener }), [onClose, opener]);
const isPaneFocused = isTop && host.isWorkspaceFocused;
useViewedSheetAgent({
sourceId: `${workspaceKey}:sheet:${tab.tabId}`,
serverId: host.serverId,
agentId: isPaneFocused && tab.target.kind === "agent" ? tab.target.agentId : null,
});
return (
<SheetPaneExitContext.Provider value={exit}>
<View style={styles.pane}>
{/* The body scrolls on its own, so the header is where the sheet drags from. */}
<SheetDragArea>
<SheetHeaderView header={header} onClose={onClose} testID="sheet-tab-header" />
</SheetDragArea>
<SheetBottomInset
style={styles.body}
role="dialog"
aria-label={descriptor.label}
testID="sheet-tab-body"
>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={host.isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</SheetBottomInset>
</View>
</SheetPaneExitContext.Provider>
);
}
/**
* The agent of the sheet on top is viewed, alongside the pane under it: its timeline catches up
* and stays live. The workspace only publishes the agents of its panes.
*/
function useViewedSheetAgent({
sourceId,
serverId,
agentId,
}: {
sourceId: string;
serverId: string;
agentId: string | null;
}) {
const viewedTimelineSync = useSessionStore(
(state) => state.sessions[serverId]?.viewedTimelineSync ?? null,
);
useEffect(() => {
if (!agentId) return;
void getHostRuntimeStore()
.prepareAgentTimeline(serverId, agentId)
.catch(() => undefined);
}, [agentId, serverId]);
useLayoutEffect(() => {
if (!agentId || !viewedTimelineSync) return;
viewedTimelineSync.replaceVisibleAgentIds(sourceId, [agentId]);
return () => viewedTimelineSync.replaceVisibleAgentIds(sourceId, []);
}, [agentId, sourceId, viewedTimelineSync]);
}
/**
* Leaves the sheet the calling pane is shown in, if any, on the way to `destination`. Answers
* whether closing the sheet already lands there, so the caller navigates only when it does not.
*/
export function useLeaveSheet(): (destination: WorkspaceTabTarget) => boolean {
const exit = useContext(SheetPaneExitContext);
return useCallback(
(destination) => {
if (!exit) return false;
exit.close();
return exit.opener !== null && workspaceTabTargetsEqual(exit.opener, destination);
},
[exit],
);
}
const styles = StyleSheet.create({
pane: {
flex: 1,
minHeight: 0,
},
body: {
flex: 1,
minHeight: 0,
},
});
+204 -32
View File
@@ -1,62 +1,131 @@
import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet";
import { useIsFocused } from "expo-router/react-navigation";
import { useCallback, useEffect, useRef } from "react";
import {
FULL_SHEET_SNAP_POINTS,
type ComponentType,
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useStore } from "zustand";
import { createStore } from "zustand/vanilla";
import { Maximize2 } from "lucide-react-native";
import {
SheetBackground,
SheetHandle,
SheetHeaderIconButton,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import {
IsolatedBottomSheetModal,
type IsolatedBottomSheetModalRef,
systemBackPress,
} from "@/components/ui/isolated-bottom-sheet-modal";
import type { PanelIconProps } from "@/panels/panel-registry";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
SheetPane,
useSheetPaneBridge,
useSheetPaneHost,
useSheetPaneHostIfAny,
} from "@/screens/workspace/sheet-pane";
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
import { useWorkspaceSheetEnvironment } from "./workspace-sheet-environment";
const SNAP_POINTS = ["92%"];
const NO_SHEETS: readonly SheetTab[] = [];
function PanelIcon({
icon: Icon,
size,
color,
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
return <Icon size={size} color={color} />;
}
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
color: theme.colors.foregroundMuted,
}));
interface SheetTabHandleValue {
header: SheetHeader;
onClose: () => void;
}
/**
* The sheet's handle, showing its header. Gorhom takes `handleComponent` as a component type and
* renders it on the far side of the portal: a new function per render would remount the header on
* every change, and no context from here reaches it. So each sheet gets one handle component for
* its lifetime, which reads the header through a store the sheet keeps current.
*/
function useSheetTabHandle(value: SheetTabHandleValue): FC<BottomSheetHandleProps> {
const [store] = useState(() => createStore<SheetTabHandleValue>(() => value));
useEffect(() => store.setState(value, true), [store, value]);
const [Handle] = useState(
() =>
function SheetTabHandle(props: BottomSheetHandleProps) {
const { header, onClose } = useStore(store);
return (
<SheetHandle {...props}>
<SheetHeaderView header={header} onClose={onClose} testID="sheet-tab-header" />
</SheetHandle>
);
},
);
return Handle;
}
export interface SheetTabsProps {
workspaceKey: string | null;
serverId: string;
workspaceId: string;
isWorkspaceFocused: boolean;
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
/** The workspace's ordinary tab close, including its unsaved-changes guard. */
onCloseTab: (tabId: string) => Promise<void>;
}
interface SheetTabSheetProps {
interface SheetTabSheetProps extends Omit<SheetTabsProps, "workspaceKey" | "onCloseTab"> {
sheet: SheetTab;
/** Only the top sheet answers Android Back; the ones below wait their turn. */
/** Only the top sheet's pane is focused and answers Android Back; the ones below wait their turn. */
isTop: boolean;
/** Runs the guarded close and resolves to whether the tab is still open. */
onClose: (tabId: string) => Promise<boolean>;
onOpenAsTab: (tabId: string) => void;
}
/**
* The workspace's sheet tabs, one sheet each, on the app-wide sheet stack. Their environment
* crosses into the app overlay host so compact sheets cover both sidebars, and a sheet opened
* from another one lands on top of it and uncovers it on close.
* crosses into the app overlay host so compact sheets cover both sidebars, and a
* sheet opened from any other sheet (a subagent chosen from the subagents list, say) lands on top of
* it and uncovers it on close. The shared sheet parts (`SheetBackground`, `SheetHandle`,
* `SheetHeaderView`) keep them looking and dragging like every other sheet.
*/
export function SheetTabs({ onCloseTab }: SheetTabsProps) {
const host = useSheetPaneHostIfAny();
return host ? (
<WorkspaceSheetTabs workspaceKey={host.workspaceKey} onCloseTab={onCloseTab} />
) : null;
}
function WorkspaceSheetTabs({
workspaceKey,
onCloseTab,
}: SheetTabsProps & { workspaceKey: string }) {
const sheets = useWorkspaceLayoutStore(
(state) => state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS,
export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) {
const sheets = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
);
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
const handleOpenAsTab = useCallback(
(tabId: string) => {
if (workspaceKey) openSheetAsTab(workspaceKey, tabId);
},
[openSheetAsTab, workspaceKey],
);
const handleClose = useCallback(
async (tabId: string) => {
await onCloseTab(tabId);
// A declined unsaved-changes prompt leaves the tab in its sheet.
const open = useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey];
const open = workspaceKey
? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]
: undefined;
return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false;
},
[onCloseTab, workspaceKey],
@@ -67,21 +136,42 @@ function WorkspaceSheetTabs({
<SheetTabSheet
// Keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
{...rest}
sheet={sheet}
isTop={index === sheets.length - 1}
onClose={handleClose}
onOpenAsTab={handleOpenAsTab}
/>
))}
</>
);
}
function SheetTabSheet({ sheet, isTop, onClose }: SheetTabSheetProps) {
function SheetTabSheet({
sheet,
isTop,
serverId,
workspaceId,
isWorkspaceFocused,
buildContent,
onClose,
onOpenAsTab,
}: SheetTabSheetProps) {
const { t } = useTranslation();
const { tab } = sheet;
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
const isScreenFocused = useIsFocused();
const { isWorkspaceFocused } = useSheetPaneHost();
const contextBridge = useSheetPaneBridge();
const contextBridge = useWorkspaceSheetEnvironment();
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.target.kind);
if (!registration) {
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
}
const descriptor = registration.useDescriptor(tab.target as never, {
serverId,
workspaceId,
tabId: tab.tabId,
});
// The sheet lives as long as its store entry: it presents on mount, and unmounting takes it off
// the stack, which uncovers the sheet below. Gorhom slides an unmounting sheet down and still
@@ -119,23 +209,105 @@ function SheetTabSheet({ sheet, isTop, onClose }: SheetTabSheetProps) {
});
}, [close, ownsBackPress]);
const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]);
const header = useMemo<SheetHeader>(
() => ({
title: descriptor.label,
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
// The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into
// the switcher, where it gets its composer back.
actions: sheet.policy.openAsTab ? (
<SheetHeaderIconButton
icon={Maximize2}
accessibilityLabel={t("workspace.sheet.openAsTab")}
onPress={handleOpenAsTab}
testID="sheet-tab-open-as-tab"
/>
) : undefined,
}),
[descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t],
);
const handleValue = useMemo<SheetTabHandleValue>(
() => ({ header, onClose: close }),
[close, header],
);
// The header is the sheet's handle: the panel scrolls on its own, so dragging the content would
// fight it, and the header is where people reach to pull the sheet down.
const SheetTabHandle = useSheetTabHandle(handleValue);
return (
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={contextBridge}
snapPoints={FULL_SHEET_SNAP_POINTS}
snapPoints={SNAP_POINTS}
enableDynamicSizing={false}
enablePanDownToClose
// The pane scrolls on its own; the sheet drags from its handle and header.
enableContentPanningGesture={false}
enableDismissOnClose={false}
backgroundComponent={SheetBackground}
handleComponent={SheetHandle}
handleComponent={SheetTabHandle}
backdropOpacity={0.45}
onChange={handleChange}
accessible={false}
>
<SheetPane sheet={sheet} onClose={close} isTop={isTop} />
{/* A covered sheet stays as live as the pane under it; only focus follows the top sheet. */}
<SheetTabBody
tab={tab}
label={descriptor.label}
isPaneFocused={isTop && isWorkspaceFocused}
isWorkspaceFocused={isWorkspaceFocused}
buildContent={buildContent}
/>
</IsolatedBottomSheetModal>
);
}
function SheetTabBody({
tab,
label,
isPaneFocused,
isWorkspaceFocused,
buildContent,
}: {
tab: SheetTab["tab"];
label: string;
/** Only the top sheet's pane is focused; the ones below are covered. */
isPaneFocused: boolean;
isWorkspaceFocused: boolean;
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
}) {
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
() => ({
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
state: tab.state,
}),
[tab],
);
const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]);
// The sheet reaches the bottom of the screen, so it owns the system navigation inset.
const { bottom: bottomInset } = useSafeAreaInsets();
const contentStyle = useMemo(
() => [styles.content, { paddingBottom: bottomInset }],
[bottomInset],
);
return (
<View style={contentStyle} role="dialog" aria-label={label} testID="sheet-tab-body">
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
minHeight: 0,
},
});
@@ -111,6 +111,28 @@ test("pane retargeting replaces the viewed agent and duplicate panes collapse to
}).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] });
});
test("the agent shown in the workspace sheet is viewed alongside the pane under it", () => {
const layout: WorkspaceLayout = {
focusedPaneId: "main",
root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } },
};
const tabs: WorkspaceTab[] = [
{ tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
];
const sheetTab: WorkspaceTab = {
tabId: "sheet",
target: { kind: "agent", agentId: "agent-child" },
createdAt: 2,
};
expect(
selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: true, focusedPaneOnly: true }),
).toEqual(["agent-a", "agent-child"]);
expect(
selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: false, focusedPaneOnly: true }),
).toEqual([]);
});
test("single-pane tablet includes its visible Explorer agent without including other splits", () => {
const layout: WorkspaceLayout = {
focusedPaneId: "main",
@@ -6,6 +6,8 @@ import { deriveWorkspacePaneState } from "./workspace-pane-state";
export function selectVisibleAgentIds(input: {
layout: WorkspaceLayout | null;
tabs: WorkspaceTab[];
/** The workspace sheet's tab, shown over the panes. */
sheetTab?: WorkspaceTab | null;
routeFocused: boolean;
focusedPaneOnly: boolean;
explorerPaneId?: string | null;
@@ -27,6 +29,7 @@ export function selectVisibleAgentIds(input: {
.target;
return target ? [target] : [];
});
if (input.sheetTab) targets.push(input.sheetTab.target);
return [
...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))),
].sort();
@@ -35,6 +38,7 @@ export function selectVisibleAgentIds(input: {
export function useVisibleAgentIds(input: {
layout: WorkspaceLayout | null;
tabs: WorkspaceTab[];
sheetTab: WorkspaceTab | null;
routeFocused: boolean;
focusedPaneOnly: boolean;
explorerPaneId?: string | null;
@@ -11,7 +11,18 @@ import {
} from "@/screens/workspace/workspace-pane-content";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { usePaneContext, usePaneFocus, type PaneContextValue } from "@/panels/pane-context";
import { definePanel, registerPanel } from "@/panels/panel-registry";
vi.mock("@/panels/register-panels", () => ({
ensurePanelsRegistered: vi.fn(),
}));
vi.mock("@/panels/panel-registry", () => ({
getPanelRegistration: () => ({
kind: "agent",
component: ProbePanel,
useDescriptor: vi.fn(),
}),
}));
interface ProbeSnapshot {
paneContextValue: PaneContextValue;
@@ -37,18 +48,6 @@ function ProbePanel() {
return null;
}
// The workspace screen registers the real panels; this probe stands in for each kind the tests open.
for (const kind of ["agent", "draft"] as const) {
registerPanel(
definePanel(kind, {
component: ProbePanel,
useDescriptor: () => {
throw new Error("The probe panel has no descriptor");
},
}),
);
}
const agentTab: WorkspaceTabDescriptor = {
key: "agent_agent-a",
tabId: "agent_agent-a",
@@ -9,6 +9,7 @@ import {
} from "@/panels/pane-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { RenderProfile } from "@/utils/render-profiler";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
@@ -55,6 +56,7 @@ export function buildWorkspacePaneContentModel({
onOpenWorkspaceFile,
onOpenImportSheet,
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`);
return {
@@ -64,7 +64,6 @@ 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";
@@ -180,8 +179,6 @@ import {
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import { SheetPaneHostProvider, type SheetPaneHost } from "@/screens/workspace/sheet-pane";
import { SheetTabs } from "@/screens/workspace/sheet-tabs";
import type { PaneHost } from "@/panels/panel-manifest";
import { WorkspaceFocusProvider } from "@/workspace/focus";
@@ -423,10 +420,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
workspaceId,
isRouteFocused,
}: WorkspaceScreenProps) {
// Every reader of the panel registry renders inside the workspace screen, so it registers the
// panels before any of them renders. A reader importing the registration instead would import
// every panel, and the panels render readers.
ensurePanelsRegistered();
const navigationFocused = useIsFocused();
useEffect(() => {
traceInstant("paseo.workspace.mount", { serverId, workspaceId });
@@ -770,17 +763,13 @@ function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
function WorkspaceContentProviders({
children,
workspaceKey,
sheetPaneHost,
}: {
children: ReactNode;
workspaceKey: string | null;
sheetPaneHost: SheetPaneHost | null;
}) {
return (
<WorkspaceFocusProvider workspaceKey={workspaceKey}>
<DiffDocumentWorkspaceCacheProvider>
<SheetPaneHostProvider value={sheetPaneHost}>{children}</SheetPaneHostProvider>
</DiffDocumentWorkspaceCacheProvider>
<DiffDocumentWorkspaceCacheProvider>{children}</DiffDocumentWorkspaceCacheProvider>
</WorkspaceFocusProvider>
);
}
@@ -1352,6 +1341,10 @@ function WorkspaceScreenContent({
const sheets = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS,
);
// Only the top sheet is on screen.
const sheetTab = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null,
);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
@@ -1423,6 +1416,7 @@ function WorkspaceScreenContent({
const visibleAgentIds = useVisibleAgentIds({
layout: workspaceLayout,
tabs: uiTabs,
sheetTab,
routeFocused: isRouteFocused,
focusedPaneOnly: syncFocusedPaneOnly,
explorerPaneId: explorerSidebarPaneId,
@@ -2921,8 +2915,6 @@ function WorkspaceScreenContent({
paneId?: string | null;
focusPaneBeforeOpen?: boolean;
host?: PaneHost;
/** Overrides the guarded tab close, for a host that closes the tab its own way. */
onCloseCurrentTab?: () => void;
}) =>
buildWorkspacePaneContentModel({
tab: input.tab,
@@ -2972,11 +2964,9 @@ function WorkspaceScreenContent({
});
}
: undefined,
onCloseCurrentTab:
input.onCloseCurrentTab ??
(() => {
void handleCloseTabById(input.tab.tabId);
}),
onCloseCurrentTab: () => {
void handleCloseTabById(input.tab.tabId);
},
onRetargetCurrentTab: (target) => {
if (!persistenceKey) {
return;
@@ -3051,36 +3041,16 @@ function WorkspaceScreenContent({
[buildPaneContentModel],
);
const buildSheetPaneContentModel = useCallback(
function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor, closeCurrentTab: () => void) {
function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor) {
return buildPaneContentModel({
tab,
paneId: null,
focusPaneBeforeOpen: false,
host: "sheet",
onCloseCurrentTab: closeCurrentTab,
});
},
[buildPaneContentModel],
);
const sheetPaneHost = useMemo<SheetPaneHost | null>(
() =>
persistenceKey
? {
workspaceKey: persistenceKey,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
isWorkspaceFocused: isRouteFocused,
buildContent: buildSheetPaneContentModel,
}
: null,
[
buildSheetPaneContentModel,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
persistenceKey,
],
);
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
if (!persistenceKey || paneFocusSuppressedRef.current) {
return;
@@ -3569,7 +3539,14 @@ function WorkspaceScreenContent({
</StreamTopOverlayProvider>
)}
</FloatingHeaderLayout>
<SheetTabs onCloseTab={handleCloseTabById} />
<SheetTabs
workspaceKey={persistenceKey}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
buildContent={buildSheetPaneContentModel}
onCloseTab={handleCloseTabById}
/>
</View>
) : (
<View style={styles.centerColumn}>
@@ -3705,7 +3682,6 @@ function WorkspaceScreenContent({
view: "files",
});
});
const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId);
const markdownPathOpener = useMemo(
() =>
createInlinePathOpener({
@@ -3713,15 +3689,8 @@ function WorkspaceScreenContent({
client,
openFile: openMarkdownFile,
openDirectory: openMarkdownDirectory,
openOutsideDirectory: openMarkdownOutsideDirectory,
}),
[
workspaceDirectory,
client,
openMarkdownFile,
openMarkdownDirectory,
openMarkdownOutsideDirectory,
],
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
);
useEffect(() => {
if (!isRouteFocused) markdownPathOpener.cancel();
@@ -3789,11 +3758,7 @@ function WorkspaceScreenContent({
return gatedWorkspaceScreen;
}
return (
<WorkspaceContentProviders
key={persistenceKey}
workspaceKey={persistenceKey}
sheetPaneHost={sheetPaneHost}
>
<WorkspaceContentProviders key={persistenceKey} workspaceKey={persistenceKey}>
<AssistantFileLinkResolverProvider
client={client}
serverId={normalizedServerId}
@@ -0,0 +1,38 @@
import React, { useCallback, useContext } from "react";
import { NavigationContext, NavigationRouteContext } from "expo-router/react-navigation";
import { useAssistantFileLinkEnvironmentBridge } from "@/assistant-file-links";
import { useComposerEditingEnvironmentBridge } from "@/composer/editing-session";
import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel";
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
import { useDiffDocumentWorkspaceCacheBridge } from "@/git/diff-document/workspace-cache";
import { HostRouteProvider, useHostRouteServerId } from "@/navigation/host-route-context";
/** The environment of the built-in compact panels; pane identity is supplied by their body. */
export function useWorkspaceSheetEnvironment(): ContextBridge {
const editing = useComposerEditingEnvironmentBridge();
const cache = useDiffDocumentWorkspaceCacheBridge();
const files = useAssistantFileLinkEnvironmentBridge();
const active = useRetainedPanelActive();
const serverId = useHostRouteServerId();
const navigation = useContext(NavigationContext);
const route = useContext(NavigationRouteContext);
return useCallback(
(children) => {
const content = editing(cache(files(children)));
return (
<NavigationContext.Provider value={navigation}>
<NavigationRouteContext.Provider value={route}>
<RetainedPanelActivity active={active}>
{serverId ? (
<HostRouteProvider serverId={serverId}>{content}</HostRouteProvider>
) : (
content
)}
</RetainedPanelActivity>
</NavigationRouteContext.Provider>
</NavigationContext.Provider>
);
},
[editing, cache, files, active, serverId, navigation, route],
);
}
@@ -6,6 +6,7 @@ import { Check } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import { getPanelRegistration, type PanelIconProps } from "@/panels/panel-registry";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
@@ -42,6 +43,7 @@ export function WorkspaceTabPresentationResolver({
workspaceId,
children,
}: WorkspaceTabPresentationResolverProps): ReactElement {
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`);
@@ -41,7 +41,6 @@ import {
normalizeLayout,
removePaneFromTree,
removeTabFromTree,
selectSheetTab,
stripEphemeralTabsFromLayout,
type SplitNode,
type SplitPane,
@@ -4497,12 +4496,6 @@ describe("compact workspace sheet", () => {
return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1);
}
function requireTopSheet(store: ReturnType<typeof createCompactStore>) {
const sheet = topSheet(store);
if (!sheet) throw new Error("Expected a sheet");
return sheet;
}
function sheetTargets(store: ReturnType<typeof createCompactStore>) {
return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map(
(sheet) => sheet.tab.target,
@@ -4654,7 +4647,7 @@ describe("compact workspace sheet", () => {
.getState()
.openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" });
store.getState().openTabInFront(workspaceKey, requireTopSheet(store));
store.getState().openSheetAsTab(workspaceKey, nextTabId ?? "");
expect(sheetTargets(store)).toEqual([]);
expect(tabKinds(store)).toEqual(["terminal", "file"]);
@@ -4690,7 +4683,7 @@ describe("compact workspace sheet", () => {
if (!sheetTabId) throw new Error("Expected a sheet tab");
store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 });
const tabId = store.getState().openTabInFront(workspaceKey, requireTopSheet(store));
const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId);
expect(tabId).toBe(sheetTabId);
expect(topSheet(store)).toBeUndefined();
@@ -4794,40 +4787,6 @@ describe("compact workspace sheet", () => {
expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
});
it("describes the sheet a reveal would show without opening it, and opens it as a tab", () => {
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
]);
const store = createCompactStore();
const rootTabId = store
.getState()
.openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" });
const sheet = selectSheetTab(store.getState(), {
workspaceKey,
target: { kind: "agent", agentId: "child" },
parentTabId: rootTabId ?? undefined,
});
expect(sheet?.parentTabId).toBe(rootTabId);
expect(
selectSheetTab(store.getState(), {
workspaceKey,
target: { kind: "agent", agentId: "root" },
}),
).toBeNull();
expect(topSheet(store)).toBeUndefined();
if (!sheet) throw new Error("Expected a sheet tab");
const tabId = store.getState().openTabInFront(workspaceKey, sheet);
expect(tabId).toBe(sheet.tab.tabId);
expect(topSheet(store)).toBeUndefined();
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(tabId);
expect(layout.parentTabIdByTabId?.[tabId ?? ""]).toBe(rootTabId);
});
it("gives a sheet target opened from inside the sheet the sheet's own parent", () => {
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
@@ -4852,7 +4811,7 @@ describe("compact workspace sheet", () => {
});
expect(topSheet(store)?.parentTabId).toBe(rootTabId);
store.getState().openTabInFront(workspaceKey, requireTopSheet(store));
store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? "");
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId);
});
@@ -156,12 +156,13 @@ interface WorkspaceLayoutStore {
* `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id.
*/
openTab: (input: OpenWorkspaceTabInput) => string | null;
/** Whether `openTab` with this input would show the target in a sheet. */
opensInSheet: (
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
) => boolean;
setLayoutMode: (mode: WorkspaceLayoutMode) => void;
/**
* Moves a sheet's tab, keeping its tab id, into the focused pane and dismisses the sheets that
* would cover it. The sheet need not be one of the workspace's sheet tabs.
*/
openTabInFront: (workspaceKey: string, sheet: SheetTab) => string | null;
/** Moves a sheet's tab, keeping its tab id, into the focused pane. */
openSheetAsTab: (workspaceKey: string, tabId: string) => string | null;
/** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */
autoOpenPullRequestTab: (
workspaceKey: string,
@@ -520,29 +521,6 @@ export function selectExplorerSidebarPaneId(
return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]);
}
/**
* The sheet tab a reveal of this target would show, or null when it opens as a tab. A caller
* that hosts the sheet itself (a list showing it in place) renders this without opening it.
*/
export function selectSheetTab(
state: Pick<WorkspaceLayoutStore, "layoutMode" | "layoutByWorkspace" | "sheetsByWorkspace">,
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "parentTabId">,
): SheetTab | null {
const workspaceKey = trimNonEmpty(input.workspaceKey);
const target = normalizeWorkspaceTabTarget(input.target);
if (!workspaceKey || !target) return null;
const policy = resolveSheetPolicy(state, workspaceKey, target, "reveal");
if (!policy) return null;
return placeTargetInSheet({
kept: undefined,
target,
intent: "reveal",
policy,
parentTabId: resolveOpenParentTabId(state, workspaceKey, input.parentTabId),
state: undefined,
});
}
/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */
export function selectPaneFocusSuspended(
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetsByWorkspace">,
@@ -844,8 +822,10 @@ function placeTargetInSheet(input: {
function promoteSheetToTab(
state: WorkspaceLayoutStore,
workspaceKey: string,
sheet: SheetTab,
tabId: string,
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
const sheet = findSheet(state, workspaceKey, tabId);
if (!sheet) return null;
const { tab } = sheet;
const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
const input = {
@@ -930,9 +910,9 @@ function dismissSheets(
function openSheetTabInFront(
state: WorkspaceLayoutStore,
workspaceKey: string,
sheet: SheetTab,
tabId: string,
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
const promoted = promoteSheetToTab(state, workspaceKey, sheet);
const promoted = promoteSheetToTab(state, workspaceKey, tabId);
if (!promoted) return null;
const next = { ...state, ...promoted.patch };
return {
@@ -1228,6 +1208,15 @@ export function createWorkspaceLayoutStore(
}));
return result.tabId;
},
opensInSheet: (input) => {
const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey);
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
if (!normalizedWorkspaceKey || !normalizedTarget) return false;
return (
resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !==
null
);
},
setLayoutMode: (mode) => {
set((state) => {
if (state.layoutMode === mode) return state;
@@ -1236,7 +1225,7 @@ export function createWorkspaceLayoutStore(
const next: WorkspaceLayoutStore = { ...state, layoutMode: mode };
for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) {
for (const sheet of sheets) {
const promoted = promoteSheetToTab(next, workspaceKey, sheet);
const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId);
Object.assign(
next,
promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId),
@@ -1246,10 +1235,11 @@ export function createWorkspaceLayoutStore(
return next;
});
},
openTabInFront: (workspaceKey, sheet) => {
openSheetAsTab: (workspaceKey, tabId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) return null;
const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, sheet);
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedWorkspaceKey || !normalizedTabId) return null;
const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId);
if (!promoted) return null;
set(promoted.patch);
return promoted.tabId;
@@ -1579,10 +1569,7 @@ export function createWorkspaceLayoutStore(
return normalizedTabId;
}
// A target sheets do not host becomes the tab it would have opened as.
const retargeted = findSheet(next, normalizedWorkspaceKey, normalizedTabId);
const promoted = retargeted
? openSheetTabInFront(next, normalizedWorkspaceKey, retargeted)
: null;
const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId);
set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId));
return promoted?.tabId ?? null;
}
+3 -1
View File
@@ -6,7 +6,8 @@ StyleSheet.configure({
breakpoints: {
xs: 0,
sm: 576,
md: 600,
medium: 600,
md: 720,
lg: 992,
xl: 1200,
},
@@ -21,6 +22,7 @@ type AppThemes = typeof REGISTERED_THEMES;
interface AppBreakpoints {
xs: number;
sm: number;
medium: number;
md: number;
lg: number;
xl: number;
+1 -1
View File
@@ -9,4 +9,4 @@ export {
type ArchiveFinishedStatus,
type UseArchiveFinishedSubagentsInput,
} from "./use-archive-finished";
export { useSubagentSheetTab } from "./sheet-tab";
export { useSubagentOpensInSheet } from "./opens-in-sheet";
@@ -9,7 +9,7 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { useSubagentSheetTab } from "./sheet-tab";
import { useSubagentOpensInSheet } from "./opens-in-sheet";
import { selectSubagentsForParent, type SubagentRow } from "./select";
vi.mock("@react-native-async-storage/async-storage", () => {
@@ -116,40 +116,28 @@ afterEach(() => {
});
});
describe("useSubagentSheetTab", () => {
it("re-renders the row's sheet as the child's tab opens and closes", () => {
describe("useSubagentOpensInSheet", () => {
it("re-renders the row's answer as the child's tab opens and closes", () => {
const row = seedParentWithChild();
const store = useWorkspaceLayoutStore.getState();
store.setLayoutMode("compact");
const target = { kind: "agent", agentId: "child" } as const;
// The row renders its answer, as the track does, so a stale render is a stale answer.
const { result } = renderHook(() => useSubagentSheetTab(workspaceKey, "parent-tab")(row));
expect(result.current?.tab.target).toEqual({ kind: "agent", agentId: "child" });
const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row));
expect(result.current).toBe(true);
let tabId: string | null = null;
act(() => {
tabId = result.current ? store.openTabInFront(workspaceKey, result.current) : null;
const sheetTabId = store.openTab({ workspaceKey, target, intent: "reveal" });
tabId = sheetTabId ? store.openSheetAsTab(workspaceKey, sheetTabId) : null;
});
expect(tabId).not.toBeNull();
expect(result.current).toBeNull();
expect(result.current).toBe(false);
act(() => {
store.closeTab(workspaceKey, tabId ?? "");
});
expect(result.current?.tab.tabId).toBe(tabId);
});
it("hands a re-rendered row the same sheet", () => {
const row = seedParentWithChild();
useWorkspaceLayoutStore.getState().setLayoutMode("compact");
const { result, rerender } = renderHook(() =>
useSubagentSheetTab(workspaceKey, "parent-tab")(row),
);
const first = result.current;
rerender();
expect(first).not.toBeNull();
expect(result.current).toBe(first);
expect(result.current).toBe(true);
});
});
@@ -0,0 +1,29 @@
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { SubagentRow } from "./select";
/** The workspace target a subagent row opens. */
export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget {
return row.kind === "provider"
? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id }
: { kind: "agent", agentId: row.id };
}
/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */
export function useSubagentOpensInSheet(
workspaceKey: string | null,
): (row: SubagentRow) => boolean {
// The rows render the answer, so this subscribes to what the answer reads: the layout mode and
// the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet,
// and a row rendered while that tab existed must re-render once it closes, or choosing the row
// would still close the list it sits in.
useWorkspaceLayoutStore((state) => state.layoutMode);
useWorkspaceLayoutStore((state) =>
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
);
return (row) =>
workspaceKey !== null &&
useWorkspaceLayoutStore
.getState()
.opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" });
}
+37 -13
View File
@@ -3,8 +3,8 @@ import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { CornerUpLeft } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StatusIcon } from "@/components/status-icon";
import { Button } from "@/components/ui/button";
import { ComposerTrackMark } from "@/composer/tracks";
import { FOOTER_HEIGHT } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
import { useParentAgent, type ParentAgent } from "./use-parent-agent";
@@ -22,7 +22,6 @@ function ParentIcon({
}
const ThemedParentIcon = withUnistyles(ParentIcon);
const ICON_SIZE = 16;
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
/** Stands in the composer's place on a subagent shown without one: who the parent is, and the way back. */
@@ -39,12 +38,18 @@ export function ParentAgentCallout({
return (
<View style={styles.rail}>
<View style={styles.callout} testID="parent-agent-callout">
<StatusIcon statusBucket={parent.statusBucket} size={ICON_SIZE} backdrop="surface1">
<ThemedParentIcon icon={parent.icon} size={ICON_SIZE} uniProps={foregroundColorMapping} />
</StatusIcon>
<Text style={styles.title} numberOfLines={1}>
{parent.label ?? t("subagents.parent.parentAgent")}
</Text>
<View style={styles.avatar}>
<ThemedParentIcon icon={parent.icon} size={18} uniProps={foregroundColorMapping} />
</View>
<View style={styles.body}>
<Text style={styles.eyebrow}>{t("subagents.parent.parent")}</Text>
<View style={styles.titleRow}>
<Text style={styles.title} numberOfLines={1}>
{parent.label ?? t("subagents.parent.parentAgent")}
</Text>
<ComposerTrackMark bucket={parent.statusBucket} />
</View>
</View>
<Button
size="sm"
variant="secondary"
@@ -60,13 +65,11 @@ export function ParentAgentCallout({
}
const styles = StyleSheet.create((theme) => ({
// The composer's rail, so the callout sits where the composer would.
rail: {
minHeight: FOOTER_HEIGHT,
alignItems: "center",
width: "100%",
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[4],
padding: theme.spacing[4],
},
callout: {
width: "100%",
@@ -81,10 +84,31 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[3],
paddingRight: { xs: theme.spacing[3], md: theme.spacing[4] },
},
// Shrinks before the button does, so Open parent never wraps or leaves the row.
title: {
avatar: {
width: 36,
height: 36,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
},
body: {
flex: 1,
minWidth: 0,
gap: theme.spacing[0.5],
},
eyebrow: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minWidth: 0,
},
title: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,

Some files were not shown because too many files have changed in this diff Show More