Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
168836c97e | ||
|
|
abc23d7721 | ||
|
|
2c23cd4927 | ||
|
|
de50243885 | ||
|
|
5b7b8d6e81 | ||
|
|
25721c7847 | ||
|
|
78f580f9e8 |
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: store-screenshots
|
||||
description: Refresh the App Store and Google Play screenshots for phones and tablets. Use when the user says "store screenshots", "app store screenshots", "play store screenshots", "iPad screenshots", "recapture the screenshots", or wants the store panels changed.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Store screenshots
|
||||
|
||||
Follow `assets/store-screenshots/README.md`. It owns the panels, the creative direction, the render command and the recapture steps. Change copy, crops and layout in `assets/store-screenshots/render.mjs`, not in the exports.
|
||||
|
||||
Working rules:
|
||||
|
||||
- Capture at native density: `--pixel-density 3` on the iPhone 16 Pro Max, `--pixel-density 2` on the iPad Pro 13-inch. The default is 1x, which is too soft for the store.
|
||||
- Every headline wraps to two lines and every subline fits on one. The renderer enforces this; rewrite the copy rather than shrink the type.
|
||||
- Use SF Pro. Never substitute another font.
|
||||
- Open `output/preview.png` and a full-size panel from each store before you report a render. Show the user the preview.
|
||||
- Do not upload screenshots to a store, a PR, or anywhere public, and do not commit, unless the user asks.
|
||||
- Stage the demo host on its own port and pick a free Metro port. Do not restart the main daemons.
|
||||
- When you learn something the next capture needs, fix the README rather than this skill.
|
||||
@@ -418,6 +418,14 @@ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.14.0 - 2026-10-29
|
||||
## 0.14.0-beta.1 - 2026-10-27
|
||||
|
||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||
|
||||
@@ -11,7 +11,7 @@ Voice settings now let you configure speech providers and download local models
|
||||
### 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))
|
||||
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||
@@ -23,7 +23,6 @@ Voice settings now let you configure speech providers and download local models
|
||||
|
||||
- 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))
|
||||
@@ -32,6 +31,7 @@ Voice settings now let you configure speech providers and download local models
|
||||
- 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
|
||||
|
||||
@@ -48,20 +48,17 @@ Voice settings now let you configure speech providers and download local models
|
||||
- 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 inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||
- Fixed model 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 0.14.0
|
||||
- Install the optional [Claude Code entrypoint plugin](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env) to launch Claude sessions with `CLAUDE_CODE_ENTRYPOINT=Paseo`, a community workaround for subscription limits draining faster ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.14.0 - 2026-10-29
|
||||
## 0.14.0-beta.1 - 2026-10-27
|
||||
|
||||
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
### 新增
|
||||
|
||||
- 插件现在可以为特定 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))
|
||||
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
- 符合条件的 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))
|
||||
@@ -32,6 +31,7 @@
|
||||
- 减少打开和分页加载大型 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))
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -48,20 +48,17 @@
|
||||
- 修复工作区外的文件夹链接使 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))
|
||||
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||
- 修复模型发现错误信息紧贴模型选择器边缘的问题 ([#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` 或转换 0.14.0 保存的语音凭据和空闲清理设置
|
||||
- 安装可选的 [Claude Code entrypoint 插件](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env),即可使用 `CLAUDE_CODE_ENTRYPOINT=Paseo` 启动 Claude 会话;这是针对订阅额度消耗过快问题的社区变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
|
||||
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
|
||||
|
||||
## 0.13.2 - 2026-10-26
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
output/preview.png
|
||||
@@ -1,102 +0,0 @@
|
||||
# Store screenshots
|
||||
|
||||
Five panels for the App Store and Google Play, in phone and tablet sets, made from native captures of the real app. Both sets tell the same story with the same copy; only the capture, the device frame and the callouts change.
|
||||
|
||||
| # | Headline | Capture | Phone callout | Tablet callout |
|
||||
| --- | --------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------- |
|
||||
| 1 | Run many coding agents at once | Sidebar grouped by status: Needs input, Ready to review, Working, Done | — (agent marks in the background) | — (agent marks in the background) |
|
||||
| 2 | Your full dev environment on your phone | Agent chat: reasoning, tool calls, final summary | — | — (Changes pane open beside the chat) |
|
||||
| 3 | Ask an agent to change the app itself | A chat asking for a plugin, the turn collapsed, the new Deploys pill | The plugin's Preview deploys sheet | The plugin's Deploys popover |
|
||||
| 4 | Review the diff before you merge | `daemon-client.ts` diff with removed and added lines | Jump to file: four changed files with counts | — (the full-width diff reads as is) |
|
||||
| 5 | A real terminal in every workspace | `git status` and a vitest run in the same worktree | The passing test tree | The passing test tree |
|
||||
|
||||
On tablets, panel 2 says "on your tablet" and panel 3 calls the popover a panel. A scene overrides its copy per device in `render.mjs`.
|
||||
|
||||
## Creative direction
|
||||
|
||||
The panels follow paseo.sh:
|
||||
|
||||
- **Canvas:** the site's `#101615` background.
|
||||
- **Type:** centred SF Pro headlines in sentence case, with no closing period. Every headline wraps to exactly two lines.
|
||||
- **Sublines:** one plain grey sentence on one line that says what you can do. The renderer fails if a headline or subline wraps differently, because the device is placed from that fixed text height.
|
||||
- **Agent marks:** panel 1 scatters faded, rotated, monochrome marks of the agents featured on the homepage behind the device. They are rendered from `packages/website/src/components/agent-icons.tsx`, so they follow the site.
|
||||
- **Device:** dark-mode captures in a plain black frame; the iPhone frame has a Dynamic Island. The device starts a fixed gap below the text and runs just off the bottom edge, so a taller canvas gets a larger device instead of a larger gap. A canvas too narrow for that, like a 3:4 iPad on Google's 9:16 tablet canvas, shows the whole device at full width with the text and device centred.
|
||||
- **Callouts:** a callout lifts one region of a capture out of the device, larger than the device around it, so the detail reads at store thumbnail size. On tablets, place callouts over empty screen so they do not hide the context they explain.
|
||||
|
||||
Everything on screen is real app UI showing one story. The hero agent adds jitter to the reconnect backoff in this repository. Its diff, test run and terminal come from a real worktree. The other workspaces fill the sidebar's status groups.
|
||||
|
||||
Panel 3 shows a real plugin. `demo/preview-deploys/` is a trusted local plugin that adds a Deploys composer pill; its popover opens as a sheet on phones and as a popover on tablets. The deploys it lists are sample data. The demo host installs it, and a scripted agent in the "Build a preview deploys plugin" workspace plays the turn that writes it.
|
||||
|
||||
## Files
|
||||
|
||||
- `raw/iphone/`: native 1320 × 2868 captures from an iPhone 16 Pro Max simulator. `files.png` and `plugin-sheet.png` are the sources for the Jump to file and Preview deploys callouts.
|
||||
- `raw/ipad/`: native 2064 × 2752 captures from an iPad Pro 13-inch (M4) simulator. `plugin-sheet.png` is the source for the Deploys popover callout.
|
||||
- `render.mjs`: the layout, copy and callout crops. Edit `SCENES`, `DEVICES` and `STORES` there.
|
||||
- `demo/`: the staged demo host the captures come from.
|
||||
- `output/apple/`: 1320 × 2868 for the iPhone 6.9-inch slot.
|
||||
- `output/google/`: 1440 × 2560 (9:16) phone screenshots, reusing the iPhone captures.
|
||||
- `output/apple-ipad/`: 2064 × 2752 for the iPad 13-inch slot.
|
||||
- `output/google-tablet/`: 1800 × 3200 (9:16) tablet screenshots, reusing the iPad captures.
|
||||
- `output/preview.png`: contact sheet with one row per store, for review only. It is ignored by Git; do not upload it to a store.
|
||||
|
||||
All exports are opaque RGB PNGs.
|
||||
|
||||
## Render
|
||||
|
||||
```sh
|
||||
SF_FONT_DIR=/path/to/fonts node assets/store-screenshots/render.mjs
|
||||
```
|
||||
|
||||
`SF_FONT_DIR` must hold `SFNS.ttf` copied from a Mac's `/System/Library/Fonts`. On macOS the renderer finds it without the variable. SF Pro is not redistributable, so it is not in the repository. The renderer fails rather than fall back to another font.
|
||||
|
||||
Each capture and callout is resized with Lanczos to the exact pixels it fills, and the page renders at device scale 1. Do not render at a higher scale and downsample: Chrome's image downscaling is what made earlier exports look soft and aliased.
|
||||
|
||||
Open `output/preview.png` and at least one full-size panel per store before you call a render done. The line guard catches wrapping, not a callout that covers the wrong thing.
|
||||
|
||||
Callout crops are in native capture pixels. To find them, overlay a 100 px grid on the capture with sharp and read the coordinates off it.
|
||||
|
||||
## Recapture
|
||||
|
||||
1. Stage the demo host. It runs an in-process daemon on port 7791 with scripted Claude Code and Codex clients, a local clone of this repository as the `paseo` project, real worktrees, and a terminal in the hero worktree:
|
||||
|
||||
```sh
|
||||
npx tsx assets/store-screenshots/demo/stage.mts
|
||||
```
|
||||
|
||||
Each run re-clones the repository into the demo home, so stale worktrees from an earlier run do not block it. The hero agent is paced so it reports a realistic "Worked for" time. Wait for `READY` in its output.
|
||||
|
||||
2. Start Metro against it on a free port. When the simulator runs on another Mac, forward Metro and the daemon to it. Other agents may already hold a Metro port; pick another one rather than stopping theirs.
|
||||
|
||||
```sh
|
||||
EXPO_PUBLIC_LOCAL_DAEMON=127.0.0.1:7791 npm run start:expo --workspace=@getpaseo/app -- --port <port>
|
||||
ssh -o ExitOnForwardFailure=yes -R <port>:127.0.0.1:<port> -R 7791:127.0.0.1:7791 <mac-host>
|
||||
```
|
||||
|
||||
3. On the simulator, install and open the dev client (`sh.paseo.debug`) at `http://127.0.0.1:<port>`. Use an iPhone 16 Pro Max for the phone set and an iPad Pro 13-inch (M4) in portrait for the tablet set.
|
||||
- Set dark appearance and override the status bar to 9:41 with full battery and signal (`xcrun simctl status_bar <device> override`).
|
||||
- On first launch, accept the open-in-app dialog, decline notifications, turn off the dev client's Tools button, and close any error toast. `agent-device react-native dismiss-overlay` clears overlays.
|
||||
- Name the host "MacBook Pro" and set the sidebar badge to Hidden.
|
||||
- Group the sidebar by status.
|
||||
- If you reset the simulator, reinstall the dev client.
|
||||
|
||||
4. Capture the sidebar first. Opening a "Ready to review" workspace marks it done. Select a Working row before you capture.
|
||||
|
||||
5. Capture every screen at native density. `agent-device screenshot` defaults to 1x, which is too soft for the store:
|
||||
|
||||
```sh
|
||||
agent-device screenshot /tmp/sidebar.png --pixel-density 3 --session store # iPhone: 1320 × 2868
|
||||
agent-device screenshot /tmp/sidebar.png --pixel-density 2 --session store # iPad: 2064 × 2752
|
||||
```
|
||||
|
||||
- **Chat:** expand the tool-call group and scroll to the bottom. On iPad, open Explorer → Changes beside the chat.
|
||||
- **Diff:** on iPhone, Explorer → Changes, then use Jump to file to reach `daemon-client.ts`. On iPad, collapse the sidebar, open a Diff tab from the main area's **+** menu and scroll to the `daemon-client.ts` hunks.
|
||||
- **Terminal:** the second workspace tab. Type the commands with `npm run cli -- terminal send-keys --host 127.0.0.1:7791 <terminal-id> "<text>" Enter` instead of the simulator keyboard, then dismiss the software keyboard.
|
||||
- **Plugin:** the "Build a preview deploys plugin" workspace with the turn collapsed, then tap **Deploys** for `plugin-sheet.png`. After editing the plugin, reload it with `npm run cli -- plugin reload --host 127.0.0.1:7791 preview-deploys`.
|
||||
|
||||
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
|
||||
|
||||
## Publish
|
||||
|
||||
The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports.
|
||||
|
||||
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
|
||||
@@ -1,84 +0,0 @@
|
||||
// Applies the demo change to a checkout of the Paseo repository.
|
||||
import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repo = process.argv[2];
|
||||
const client = path.join(repo, "packages/client/src");
|
||||
|
||||
copyFileSync(
|
||||
path.join(here, "reconnect-delay.ts"),
|
||||
path.join(client, "connection/reconnect-delay.ts"),
|
||||
);
|
||||
copyFileSync(
|
||||
path.join(here, "reconnect-delay.test.ts"),
|
||||
path.join(client, "connection/reconnect-delay.test.ts"),
|
||||
);
|
||||
|
||||
function edit(file, pairs) {
|
||||
let text = readFileSync(file, "utf8");
|
||||
for (const [from, to] of pairs) {
|
||||
if (!text.includes(from)) throw new Error(`Missing anchor in ${file}: ${from.slice(0, 60)}`);
|
||||
text = text.replace(from, to);
|
||||
}
|
||||
writeFileSync(file, text);
|
||||
}
|
||||
|
||||
edit(path.join(client, "daemon-client.ts"), [
|
||||
[
|
||||
`import { CreationClient } from "./creation/index.js";\n`,
|
||||
`import { CreationClient } from "./creation/index.js";\nimport { getReconnectDelayMs } from "./connection/reconnect-delay.js";\n`,
|
||||
],
|
||||
[
|
||||
` private reconnectAttempt = 0;\n`,
|
||||
` private reconnectAttempt = 0;\n private nextReconnectDelay: { attempt: number; delayMs: number } | null = null;\n`,
|
||||
],
|
||||
[
|
||||
` nextRetryAt: new Date(
|
||||
Date.now() +
|
||||
Math.min(
|
||||
(this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) *
|
||||
2 ** this.reconnectAttempt,
|
||||
this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
|
||||
),
|
||||
).toISOString(),
|
||||
`,
|
||||
` nextRetryAt: new Date(Date.now() + this.getNextReconnectDelayMs()).toISOString(),
|
||||
`,
|
||||
],
|
||||
[
|
||||
` private armReconnectTimer(): void {
|
||||
const attempt = this.reconnectAttempt;
|
||||
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
|
||||
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
|
||||
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
|
||||
`,
|
||||
` // The countdown and the timer must agree, so each attempt draws its jitter once.
|
||||
private getNextReconnectDelayMs(): number {
|
||||
const attempt = this.reconnectAttempt;
|
||||
if (this.nextReconnectDelay?.attempt !== attempt) {
|
||||
this.nextReconnectDelay = {
|
||||
attempt,
|
||||
delayMs: getReconnectDelayMs(attempt, {
|
||||
baseDelayMs: this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS,
|
||||
maxDelayMs: this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return this.nextReconnectDelay.delayMs;
|
||||
}
|
||||
|
||||
private armReconnectTimer(): void {
|
||||
const attempt = this.reconnectAttempt;
|
||||
const delay = this.getNextReconnectDelayMs();
|
||||
`,
|
||||
],
|
||||
]);
|
||||
|
||||
edit(path.join(repo, "CHANGELOG.md"), [
|
||||
[
|
||||
`# Changelog\n\n`,
|
||||
`# Changelog\n\n## Unreleased\n\n### Fixed\n\n- Spread reconnect attempts after a relay restart instead of retrying every client at once\n\n`,
|
||||
],
|
||||
]);
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getReconnectDelayMs } from "./reconnect-delay";
|
||||
|
||||
const options = { baseDelayMs: 1500, maxDelayMs: 30_000 };
|
||||
|
||||
describe("getReconnectDelayMs", () => {
|
||||
it("doubles the ceiling on each attempt", () => {
|
||||
const top = () => 1;
|
||||
expect(getReconnectDelayMs(0, options, top)).toBe(1500);
|
||||
expect(getReconnectDelayMs(1, options, top)).toBe(3000);
|
||||
expect(getReconnectDelayMs(2, options, top)).toBe(6000);
|
||||
});
|
||||
|
||||
it("never waits longer than the max delay", () => {
|
||||
expect(getReconnectDelayMs(12, options, () => 1)).toBe(30_000);
|
||||
});
|
||||
|
||||
it("spreads retries between half and the full ceiling", () => {
|
||||
expect(getReconnectDelayMs(3, options, () => 0)).toBe(6000);
|
||||
expect(getReconnectDelayMs(3, options, () => 0.5)).toBe(9000);
|
||||
expect(getReconnectDelayMs(3, options, () => 1)).toBe(12_000);
|
||||
});
|
||||
|
||||
it("keeps the first retry under two seconds", () => {
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
expect(getReconnectDelayMs(0, options)).toBeLessThanOrEqual(1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
export interface ReconnectDelayOptions {
|
||||
baseDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential backoff with equal jitter. Half of each delay is fixed and half
|
||||
* is random, so a relay restart spreads reconnects out instead of every client
|
||||
* retrying on the same tick.
|
||||
*/
|
||||
export function getReconnectDelayMs(
|
||||
attempt: number,
|
||||
options: ReconnectDelayOptions,
|
||||
random: () => number = Math.random,
|
||||
): number {
|
||||
const ceiling = Math.min(options.baseDelayMs * 2 ** attempt, options.maxDelayMs);
|
||||
return Math.round(ceiling / 2 + random() * (ceiling / 2));
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
// The plugin the demo agent "builds" in the store screenshots: a Deploys pill on
|
||||
// every composer that opens the latest preview deploy for each branch. The
|
||||
// deploys are fixed sample data; the UI is a real plugin surface.
|
||||
import type {
|
||||
PluginButtonContentProps,
|
||||
PluginButtonRegistration,
|
||||
PluginClientContext,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
type DeployStatus = "ready" | "building" | "failed";
|
||||
|
||||
interface Deploy {
|
||||
branch: string;
|
||||
status: DeployStatus;
|
||||
detail: string;
|
||||
age: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
const DEPLOYS: Deploy[] = [
|
||||
{ branch: "fix/reconnect-jitter", status: "ready", detail: "Built in 48s", age: "2m" },
|
||||
{
|
||||
branch: "perf/provider-snapshot-cache",
|
||||
status: "building",
|
||||
detail: "Bundling app",
|
||||
age: "now",
|
||||
progress: 0.64,
|
||||
},
|
||||
{ branch: "feat/remember-terminal", status: "ready", detail: "Built in 51s", age: "18m" },
|
||||
{
|
||||
branch: "i18n/ko-settings",
|
||||
status: "failed",
|
||||
detail: "Missing key settings.notifications",
|
||||
age: "26m",
|
||||
},
|
||||
];
|
||||
|
||||
function DeploysSheet({ theme, layout }: PluginButtonContentProps) {
|
||||
const mono = layout.platform === "ios" ? "Menlo" : "monospace";
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
// Popovers on tablets and desktop are narrow by default; give the
|
||||
// branch names room. Phones show the sheet at full width.
|
||||
body: { gap: 14, width: layout.compact ? undefined : 400 },
|
||||
production: {
|
||||
padding: 14,
|
||||
gap: 6,
|
||||
borderRadius: 12,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
productionTop: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 },
|
||||
label: { color: theme.colors.foregroundMuted, fontSize: 13 },
|
||||
version: { color: theme.colors.foreground, fontSize: 22, fontWeight: "600" as const },
|
||||
live: { color: theme.colors.statusSuccess, fontSize: 13, fontWeight: "600" as const },
|
||||
heading: { color: theme.colors.foregroundMuted, fontSize: 13, marginTop: 4 },
|
||||
list: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row" as const,
|
||||
alignItems: "center" as const,
|
||||
gap: 12,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
divider: { height: 1, backgroundColor: theme.colors.border },
|
||||
text: { flex: 1, gap: 4 },
|
||||
branch: { color: theme.colors.foreground, fontSize: 14, fontFamily: mono },
|
||||
detail: { color: theme.colors.foregroundMuted, fontSize: 13 },
|
||||
failed: { color: theme.colors.statusDanger, fontSize: 13 },
|
||||
age: { color: theme.colors.foregroundMuted, fontSize: 13 },
|
||||
track: {
|
||||
height: 4,
|
||||
marginTop: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
}),
|
||||
[theme, mono, layout.compact],
|
||||
);
|
||||
const dotColor: Record<DeployStatus, string> = {
|
||||
ready: theme.colors.statusSuccess,
|
||||
building: theme.colors.statusWarning,
|
||||
failed: theme.colors.statusDanger,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.body}>
|
||||
<View style={styles.production}>
|
||||
<View style={styles.productionTop}>
|
||||
<Dot color={theme.colors.statusSuccess} />
|
||||
<Text style={styles.live}>Live</Text>
|
||||
<Text style={styles.label}>Production · main</Text>
|
||||
</View>
|
||||
<Text style={styles.version}>v0.14.0-beta.1</Text>
|
||||
<Text style={styles.label}>Deployed 3h ago</Text>
|
||||
</View>
|
||||
<Text style={styles.heading}>Previews</Text>
|
||||
<View style={styles.list}>
|
||||
{DEPLOYS.map((deploy, index) => (
|
||||
<View key={deploy.branch}>
|
||||
{index > 0 ? <View style={styles.divider} /> : null}
|
||||
<View style={styles.row}>
|
||||
<Dot color={dotColor[deploy.status]} />
|
||||
<View style={styles.text}>
|
||||
<Text style={styles.branch} numberOfLines={1}>
|
||||
{deploy.branch}
|
||||
</Text>
|
||||
<Text style={deploy.status === "failed" ? styles.failed : styles.detail}>
|
||||
{deploy.detail}
|
||||
</Text>
|
||||
{deploy.progress !== undefined ? (
|
||||
<Progress
|
||||
value={deploy.progress}
|
||||
track={styles.track}
|
||||
color={theme.colors.statusWarning}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.age}>{deploy.age}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Progress({ value, track, color }: { value: number; track: object; color: string }) {
|
||||
const fill = useMemo(
|
||||
() => ({ width: `${value * 100}%` as const, height: 4, backgroundColor: color }),
|
||||
[value, color],
|
||||
);
|
||||
return (
|
||||
<View style={track}>
|
||||
<View style={fill} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ color }: { color: string }) {
|
||||
const style = useMemo(
|
||||
() => ({ width: 8, height: 8, borderRadius: 4, backgroundColor: color }),
|
||||
[color],
|
||||
);
|
||||
return <View style={style} />;
|
||||
}
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
const pills = new Map<string, PluginButtonRegistration>();
|
||||
const lifetime = new AbortController();
|
||||
const register = (agent: { id: string; workspaceId?: string | null }) => {
|
||||
if (lifetime.signal.aborted || !agent.workspaceId) return;
|
||||
pills.get(agent.id)?.remove();
|
||||
pills.set(
|
||||
agent.id,
|
||||
client.addComposerPill({
|
||||
id: "deploys",
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
button: {
|
||||
title: "Preview deploys",
|
||||
icon: "Rocket",
|
||||
label: "Deploys",
|
||||
behavior: { kind: "popover", Content: DeploysSheet },
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
void client.paseo.agents
|
||||
.list({ subscribe: {}, signal: lifetime.signal })
|
||||
.then(({ subscription }) => {
|
||||
subscription.subscribe({
|
||||
snapshot: ({ entries }) => {
|
||||
for (const pill of pills.values()) pill.remove();
|
||||
pills.clear();
|
||||
for (const { agent } of entries) register(agent);
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "agent_update") return;
|
||||
const update = message.payload;
|
||||
if (update.kind === "remove") {
|
||||
pills.get(update.agentId)?.remove();
|
||||
pills.delete(update.agentId);
|
||||
} else register(update.agent);
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!lifetime.signal.aborted) console.error("Agent observation failed", error);
|
||||
});
|
||||
return () => {
|
||||
lifetime.abort();
|
||||
for (const pill of pills.values()) pill.remove();
|
||||
pills.clear();
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"id": "preview-deploys",
|
||||
"requirements": { "paseo": ">=0.8.0" }
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
// Scripted Claude Code and Codex clients for staging store screenshots.
|
||||
// Each agent replays a fixed conversation through the real daemon, so the app
|
||||
// renders real timeline rows, permission prompts, and running states.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
ToolCallDetail,
|
||||
} from "../../../packages/server/src/server/agent/agent-sdk-types.js";
|
||||
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
export type ScriptStep =
|
||||
| { kind: "say"; text: string }
|
||||
| { kind: "think"; text: string }
|
||||
| { kind: "tool"; name: string; detail: ToolCallDetail }
|
||||
| { kind: "todo"; items: { text: string; completed: boolean }[] }
|
||||
| { kind: "wait"; ms: number }
|
||||
| { kind: "running-tool"; name: string; detail: ToolCallDetail }
|
||||
| { kind: "ask"; name: string; title: string; detail: ToolCallDetail };
|
||||
|
||||
export interface AgentScript {
|
||||
/** Matched against the start of the prompt that opens the turn. */
|
||||
prompt: string;
|
||||
steps: ScriptStep[];
|
||||
/** Leave the turn open after the last step. */
|
||||
keepRunning?: boolean;
|
||||
/** Pause between steps, so "Worked for" reflects a believable duration. */
|
||||
stepDelayMs?: number;
|
||||
}
|
||||
|
||||
const CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
};
|
||||
|
||||
const MODELS: Record<string, AgentModelDefinition[]> = {
|
||||
claude: [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-5-5",
|
||||
label: "Opus 5.5",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium", isDefault: true },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
{ provider: "claude", id: "claude-sonnet-5-5", label: "Sonnet 5.5" },
|
||||
],
|
||||
codex: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.6-sol",
|
||||
label: "GPT-5.6-Sol",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium", isDefault: true },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function promptText(prompt: AgentPromptInput): string {
|
||||
if (typeof prompt === "string") return prompt;
|
||||
return prompt
|
||||
.map((block) => ("text" in block && typeof block.text === "string" ? block.text : ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
class ScriptedSession implements AgentSession {
|
||||
readonly capabilities = CAPABILITIES;
|
||||
readonly id = randomUUID();
|
||||
private readonly listeners = new Set<(event: AgentStreamEvent) => void>();
|
||||
private readonly history: AgentStreamEvent[] = [];
|
||||
private pending: AgentPermissionRequest[] = [];
|
||||
private activeTurnId: string | null = null;
|
||||
|
||||
constructor(
|
||||
readonly provider: string,
|
||||
private readonly config: AgentSessionConfig,
|
||||
private readonly scripts: AgentScript[],
|
||||
) {}
|
||||
|
||||
private emit(event: AgentStreamEvent): void {
|
||||
const tagged = this.activeTurnId ? { ...event, turnId: this.activeTurnId } : event;
|
||||
this.history.push(tagged);
|
||||
for (const listener of this.listeners) listener(tagged);
|
||||
}
|
||||
|
||||
private timeline(item: AgentTimelineItem): void {
|
||||
this.emit({ type: "timeline", provider: this.provider, item });
|
||||
}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||
}
|
||||
|
||||
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
const turnId = randomUUID();
|
||||
this.activeTurnId = turnId;
|
||||
const text = promptText(prompt);
|
||||
const script = this.scripts.find((candidate) => text.startsWith(candidate.prompt));
|
||||
setTimeout(() => void this.play(text, script), 50);
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
private async play(text: string, script: AgentScript | undefined): Promise<void> {
|
||||
this.emit({ type: "turn_started", provider: this.provider });
|
||||
this.timeline({ type: "user_message", text, messageId: randomUUID() });
|
||||
const delay = script?.stepDelayMs ?? 400;
|
||||
for (const step of script?.steps ?? [{ kind: "say", text: "Done." } as const]) {
|
||||
await new Promise((resolve) => setTimeout(resolve, step.kind === "wait" ? step.ms : delay));
|
||||
this.playStep(step);
|
||||
if (step.kind === "ask") return;
|
||||
}
|
||||
if (script?.keepRunning) return;
|
||||
this.emit({
|
||||
type: "turn_completed",
|
||||
provider: this.provider,
|
||||
usage: { inputTokens: 48_210, outputTokens: 3_904 },
|
||||
});
|
||||
this.activeTurnId = null;
|
||||
}
|
||||
|
||||
private playStep(step: ScriptStep): void {
|
||||
switch (step.kind) {
|
||||
case "say":
|
||||
this.timeline({ type: "assistant_message", text: step.text, messageId: randomUUID() });
|
||||
return;
|
||||
case "think":
|
||||
this.timeline({ type: "reasoning", text: step.text });
|
||||
return;
|
||||
case "todo":
|
||||
this.timeline({ type: "todo", items: step.items });
|
||||
return;
|
||||
case "wait":
|
||||
return;
|
||||
case "tool":
|
||||
case "running-tool": {
|
||||
const callId = randomUUID();
|
||||
this.timeline({
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: step.name,
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: step.detail,
|
||||
});
|
||||
if (step.kind === "tool") {
|
||||
this.timeline({
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: step.name,
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: step.detail,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "ask": {
|
||||
const request: AgentPermissionRequest = {
|
||||
id: randomUUID(),
|
||||
provider: this.provider,
|
||||
name: step.name,
|
||||
kind: "tool",
|
||||
title: step.title,
|
||||
detail: step.detail,
|
||||
};
|
||||
this.pending = [request];
|
||||
this.emit({ type: "permission_requested", provider: this.provider, request });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.listeners.add(callback);
|
||||
return () => this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield* this.history;
|
||||
}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: this.config.model ?? MODELS[this.provider]?.[0]?.id ?? null,
|
||||
thinkingOptionId: this.config.thinkingOptionId ?? "medium",
|
||||
modeId: this.config.modeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes(): Promise<AgentMode[]> {
|
||||
return getAgentProviderDefinition(this.provider).modes;
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<string | null> {
|
||||
return this.config.modeId ?? null;
|
||||
}
|
||||
|
||||
async setMode(): Promise<void> {}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
async respondToPermission(requestId: string, resolution: AgentPermissionResponse) {
|
||||
this.pending = [];
|
||||
this.emit({ type: "permission_resolved", provider: this.provider, requestId, resolution });
|
||||
}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle {
|
||||
return { provider: this.provider, sessionId: this.id };
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
if (!this.activeTurnId) return;
|
||||
this.emit({ type: "turn_canceled", provider: this.provider, reason: "Interrupted" });
|
||||
this.activeTurnId = null;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class ScriptedAgentClient implements AgentClient {
|
||||
readonly capabilities = CAPABILITIES;
|
||||
|
||||
constructor(
|
||||
readonly provider: string,
|
||||
private readonly scripts: AgentScript[],
|
||||
) {}
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new ScriptedSession(this.provider, config, this.scripts);
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
return new ScriptedSession(
|
||||
this.provider,
|
||||
{ provider: this.provider, cwd: overrides?.cwd ?? process.cwd(), ...overrides },
|
||||
this.scripts,
|
||||
);
|
||||
}
|
||||
|
||||
async fetchCatalog() {
|
||||
return {
|
||||
models: MODELS[this.provider] ?? [],
|
||||
modes: getAgentProviderDefinition(this.provider).modes,
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
// Stages the demo host used for store screenshots.
|
||||
//
|
||||
// npx tsx assets/store-screenshots/demo/stage.mts
|
||||
//
|
||||
// Starts an in-process daemon on DEMO_PORT (default 7791) with scripted Claude
|
||||
// Code and Codex clients, adds a local clone of this repository as the `paseo`
|
||||
// project, cuts real worktrees, applies a real change to the hero worktree, and
|
||||
// opens a terminal there. Leave it running while you capture.
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createTestPaseoDaemon } from "../../../packages/server/src/server/test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../../../packages/server/src/server/test-utils/daemon-client.js";
|
||||
import { type AgentScript, ScriptedAgentClient } from "./scripted-agents.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, "../../..");
|
||||
const demoRoot = path.join(repoRoot, ".dev/store-capture/demo");
|
||||
const codeRoot = path.join(demoRoot, "code");
|
||||
const pluginDir = path.join(here, "preview-deploys");
|
||||
const port = Number(process.env.DEMO_PORT ?? 7791);
|
||||
const minute = 60_000;
|
||||
|
||||
const claudeScripts: AgentScript[] = [];
|
||||
const codexScripts: AgentScript[] = [];
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync("git", ["-c", "user.name=Mo", "-c", "user.email=mo@paseo.sh", ...args], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
function run(cwd: string, command: string): string {
|
||||
return execSync(command, { cwd, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0" } });
|
||||
}
|
||||
|
||||
async function prepareRepos(): Promise<void> {
|
||||
// A fresh clone each run: worktrees from an earlier run stay registered in it.
|
||||
// Worktree setup (npm ci, builds) is dropped so new worktrees open instantly.
|
||||
const paseo = path.join(codeRoot, "paseo");
|
||||
await rm(paseo, { recursive: true, force: true });
|
||||
await mkdir(codeRoot, { recursive: true });
|
||||
execFileSync("git", ["clone", "-q", "--local", repoRoot, paseo]);
|
||||
git(paseo, "checkout", "-q", "-B", "main");
|
||||
git(paseo, "remote", "remove", "origin");
|
||||
const config = JSON.parse(await readFile(path.join(paseo, "paseo.json"), "utf8"));
|
||||
delete config.worktree;
|
||||
await writeFile(path.join(paseo, "paseo.json"), `${JSON.stringify(config, null, 2)}\n`);
|
||||
git(paseo, "commit", "-qam", "chore: skip worktree setup in demo");
|
||||
const dotfiles = path.join(codeRoot, "dotfiles");
|
||||
if (!existsSync(dotfiles)) {
|
||||
await mkdir(dotfiles, { recursive: true });
|
||||
await writeFile(path.join(dotfiles, "tmux.conf"), "set -g mouse on\nset -g base-index 1\n");
|
||||
await writeFile(path.join(dotfiles, "zshrc"), "export EDITOR=nvim\nalias g=git\n");
|
||||
git(dotfiles, "init", "-q", "-b", "main");
|
||||
git(dotfiles, "add", ".");
|
||||
git(dotfiles, "commit", "-qm", "Initial dotfiles");
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await prepareRepos();
|
||||
const home = path.join(demoRoot, "home");
|
||||
await rm(home, { recursive: true, force: true });
|
||||
await mkdir(home, { recursive: true });
|
||||
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: {
|
||||
claude: new ScriptedAgentClient("claude", claudeScripts),
|
||||
codex: new ScriptedAgentClient("codex", codexScripts),
|
||||
},
|
||||
listen: "127.0.0.1",
|
||||
listenPort: port,
|
||||
paseoHomeRoot: home,
|
||||
mcpEnabled: false,
|
||||
cleanup: false,
|
||||
pluginsEnabled: true,
|
||||
plugins: {
|
||||
"preview-deploys": { source: "directory", path: pluginDir, enabled: true },
|
||||
},
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.14.0",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
const paseo = await client.addProject(path.join(codeRoot, "paseo"));
|
||||
const dotfiles = await client.addProject(path.join(codeRoot, "dotfiles"));
|
||||
const paseoProjectId = paseo.project!.projectId;
|
||||
|
||||
async function worktree(title: string, branchName: string) {
|
||||
const result = await client.createWorkspace({
|
||||
title,
|
||||
source: {
|
||||
kind: "worktree",
|
||||
projectId: paseoProjectId,
|
||||
action: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName,
|
||||
},
|
||||
});
|
||||
if (!result.workspace) throw new Error(`No workspace for ${title}: ${JSON.stringify(result)}`);
|
||||
return result.workspace;
|
||||
}
|
||||
|
||||
async function agent(input: {
|
||||
provider: "claude" | "codex";
|
||||
workspace: { id: string; workspaceDirectory?: string | null };
|
||||
cwd: string;
|
||||
title: string;
|
||||
script: AgentScript;
|
||||
}) {
|
||||
(input.provider === "claude" ? claudeScripts : codexScripts).push(input.script);
|
||||
return client.createAgent({
|
||||
provider: input.provider,
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspace.id,
|
||||
title: input.title,
|
||||
initialPrompt: input.script.prompt,
|
||||
});
|
||||
}
|
||||
|
||||
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Done: finished earlier and already read.
|
||||
const release = await worktree("Release v0.14.0-beta.1", "release/0.14.0-beta.1");
|
||||
const releaseAgent = await agent({
|
||||
provider: "codex",
|
||||
workspace: release,
|
||||
cwd: release.workspaceDirectory!,
|
||||
title: "Release v0.14.0-beta.1",
|
||||
script: {
|
||||
prompt: "Cut 0.14.0-beta.1 from main and draft the release notes.",
|
||||
steps: [
|
||||
{
|
||||
kind: "tool",
|
||||
name: "shell",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm run release:beta",
|
||||
output: "Tagged v0.14.0-beta.1\n",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: "Tagged `v0.14.0-beta.1` and drafted the release notes. CI is building the desktop and mobile artifacts.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const dotfilesWorkspace = (
|
||||
await client.createWorkspace({
|
||||
title: "Add a tmux status line",
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: path.join(codeRoot, "dotfiles"),
|
||||
projectId: dotfiles.project!.projectId,
|
||||
},
|
||||
})
|
||||
).workspace!;
|
||||
const tmuxAgent = await agent({
|
||||
provider: "claude",
|
||||
workspace: dotfilesWorkspace,
|
||||
cwd: path.join(codeRoot, "dotfiles"),
|
||||
title: "Add a tmux status line",
|
||||
script: {
|
||||
prompt: "Add a minimal tmux status line with the session name and battery.",
|
||||
steps: [
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Edit",
|
||||
detail: {
|
||||
type: "edit",
|
||||
filePath: "tmux.conf",
|
||||
oldString: "",
|
||||
newString: "set -g status-right '#S · #{battery_percentage}'",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: "Added a status line with the session name on the left and battery on the right.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await pause(3000);
|
||||
await client.clearAgentAttention([releaseAgent.id, tmuxAgent.id]);
|
||||
|
||||
// Working: turns that stay open while you capture.
|
||||
const ipad = await worktree(
|
||||
"Keep the composer above the iPad keyboard",
|
||||
"fix/ipad-keyboard-inset",
|
||||
);
|
||||
await agent({
|
||||
provider: "codex",
|
||||
workspace: ipad,
|
||||
cwd: ipad.workspaceDirectory!,
|
||||
title: "Keep the composer above the iPad keyboard",
|
||||
script: {
|
||||
prompt:
|
||||
"On iPad with a hardware keyboard attached, the composer jumps up as if the software keyboard were open. Find out why and fix it.",
|
||||
keepRunning: true,
|
||||
steps: [
|
||||
{
|
||||
kind: "tool",
|
||||
name: "shell",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: 'rg -n "keyboardHeight" packages/app/src',
|
||||
output:
|
||||
"packages/app/src/keyboard/shift/internal/provider.ts:41: const keyboardHeight = useSharedValue(0);\n",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "read",
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "packages/app/src/keyboard/shift/internal/provider.ts",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: "The shift provider trusts the keyboard frame event. With a hardware keyboard, iPadOS still reports the shortcuts bar as a keyboard frame, so we lift the composer by its full height. Checking the frame against the screen bottom next.",
|
||||
},
|
||||
{
|
||||
kind: "running-tool",
|
||||
name: "shell",
|
||||
detail: { type: "shell", command: "npx vitest run packages/app/src/keyboard" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const snapshots = await worktree(
|
||||
"Cache provider snapshots on disk",
|
||||
"perf/provider-snapshot-cache",
|
||||
);
|
||||
await agent({
|
||||
provider: "claude",
|
||||
workspace: snapshots,
|
||||
cwd: snapshots.workspaceDirectory!,
|
||||
title: "Cache provider snapshots on disk",
|
||||
script: {
|
||||
prompt:
|
||||
"Cold start spends about two seconds probing providers. Cache the last provider snapshot on disk and refresh it in the background.",
|
||||
keepRunning: true,
|
||||
steps: [
|
||||
{
|
||||
kind: "todo",
|
||||
items: [
|
||||
{ text: "Find where snapshots are built", completed: true },
|
||||
{ text: "Persist the last snapshot per host", completed: false },
|
||||
{ text: "Refresh in the background after boot", completed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Read",
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "running-tool",
|
||||
name: "Edit",
|
||||
detail: {
|
||||
type: "edit",
|
||||
filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Needs input: waiting on a permission.
|
||||
const korean = await worktree("Korean translations for Settings", "i18n/ko-settings");
|
||||
await agent({
|
||||
provider: "claude",
|
||||
workspace: korean,
|
||||
cwd: korean.workspaceDirectory!,
|
||||
title: "Korean translations for Settings",
|
||||
script: {
|
||||
prompt: "Translate the new Settings strings into Korean and open a PR.",
|
||||
steps: [
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Read",
|
||||
detail: { type: "read", filePath: "packages/app/src/i18n/resources/en.ts" },
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Edit",
|
||||
detail: { type: "edit", filePath: "packages/app/src/i18n/resources/ko.ts" },
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Bash",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm run test --workspace=@getpaseo/app -- i18n",
|
||||
output: "Test Files 3 passed (3)\n Tests 41 passed (41)\n",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
{ kind: "say", text: "Translated 18 strings. I'll push the branch so I can open the PR." },
|
||||
{
|
||||
kind: "ask",
|
||||
name: "Bash",
|
||||
title: "Bash",
|
||||
detail: { type: "shell", command: "git push -u origin i18n/ko-settings" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Ready to review: a second finished agent.
|
||||
const terminals = await worktree(
|
||||
"Remember the last terminal per workspace",
|
||||
"feat/remember-terminal",
|
||||
);
|
||||
await agent({
|
||||
provider: "codex",
|
||||
workspace: terminals,
|
||||
cwd: terminals.workspaceDirectory!,
|
||||
title: "Remember the last terminal per workspace",
|
||||
script: {
|
||||
prompt:
|
||||
"When I switch back to a workspace, open the terminal I was using last instead of the first one.",
|
||||
steps: [
|
||||
{
|
||||
kind: "tool",
|
||||
name: "shell",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: 'rg -n "activeTerminalId" packages/app/src',
|
||||
output: "",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: "Workspaces now remember their last focused terminal and reopen it when you switch back. The choice is stored per host, so it survives restarts.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Plugin: the agent "built" the preview-deploys plugin this daemon runs.
|
||||
const plugin = await worktree("Build a preview deploys plugin", "plugins/preview-deploys");
|
||||
const pluginSource = (file: string) => run(pluginDir, `cat ${file}`);
|
||||
await agent({
|
||||
provider: "claude",
|
||||
workspace: plugin,
|
||||
cwd: plugin.workspaceDirectory!,
|
||||
title: "Build a preview deploys plugin",
|
||||
script: {
|
||||
prompt: "Build me a plugin that shows our preview deploys right next to the composer.",
|
||||
stepDelayMs: 6_000,
|
||||
steps: [
|
||||
{ kind: "tool", name: "Read", detail: { type: "read", filePath: "docs/plugins.md" } },
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Write",
|
||||
detail: {
|
||||
type: "write",
|
||||
filePath: "plugins/preview-deploys/paseo-plugin.json",
|
||||
content: pluginSource("paseo-plugin.json"),
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Write",
|
||||
detail: {
|
||||
type: "write",
|
||||
filePath: "plugins/preview-deploys/index.client.tsx",
|
||||
content: pluginSource("index.client.tsx"),
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Bash",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "paseo plugin install ./plugins/preview-deploys",
|
||||
output: "Installed preview-deploys\n",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: "Done. **Deploys** now sits above the composer in every workspace. Tap it to see production and the latest preview for each branch, with build progress and failures.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Hero: Claude finished a real change in a real worktree.
|
||||
const jitter = await worktree("Add jitter to reconnect backoff", "fix/reconnect-jitter");
|
||||
const cwd = jitter.workspaceDirectory!;
|
||||
await symlink(path.join(repoRoot, "node_modules"), path.join(cwd, "node_modules"));
|
||||
execFileSync("node", [path.join(here, "change/apply.mjs"), cwd]);
|
||||
const clientDiff = git(cwd, "diff", "--", "packages/client/src/daemon-client.ts");
|
||||
const vitest = run(
|
||||
cwd,
|
||||
"npx vitest run packages/client/src/connection/reconnect-delay.test.ts",
|
||||
).replace(/^ RUN .*\n/m, "");
|
||||
const readExcerpt = run(cwd, "sed -n '7067,7086p' packages/client/src/daemon-client.ts");
|
||||
await agent({
|
||||
provider: "claude",
|
||||
workspace: jitter,
|
||||
cwd,
|
||||
title: "Add jitter to reconnect backoff",
|
||||
script: {
|
||||
prompt:
|
||||
'When the relay restarts, every phone reconnects at the same moment and we get a spike. Add jitter to the reconnect backoff, keep the 30s cap, and make sure the "Retrying in" countdown still matches the real delay.',
|
||||
stepDelayMs: 7_000,
|
||||
steps: [
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Grep",
|
||||
detail: {
|
||||
type: "search",
|
||||
toolName: "grep",
|
||||
query: "reconnectAttempt",
|
||||
filePaths: ["packages/client/src/daemon-client.ts"],
|
||||
numFiles: 1,
|
||||
numMatches: 9,
|
||||
mode: "files_with_matches",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Read",
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "packages/client/src/daemon-client.ts",
|
||||
offset: 7040,
|
||||
limit: 60,
|
||||
content: readExcerpt,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: "The delay is calculated twice: once for `nextRetryAt`, which drives the countdown, and again when the timer is armed. Both use plain doubling, so every client retries on the same schedule. If I add randomness in both places the countdown will be wrong, so I'll draw the jitter once per attempt and share it.",
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Write",
|
||||
detail: {
|
||||
type: "write",
|
||||
filePath: "packages/client/src/connection/reconnect-delay.ts",
|
||||
content: run(cwd, "cat packages/client/src/connection/reconnect-delay.ts"),
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Edit",
|
||||
detail: {
|
||||
type: "edit",
|
||||
filePath: "packages/client/src/daemon-client.ts",
|
||||
unifiedDiff: clientDiff,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Write",
|
||||
detail: {
|
||||
type: "write",
|
||||
filePath: "packages/client/src/connection/reconnect-delay.test.ts",
|
||||
content: run(cwd, "cat packages/client/src/connection/reconnect-delay.test.ts"),
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Bash",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npx vitest run packages/client/src/connection/reconnect-delay.test.ts",
|
||||
output: vitest,
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
name: "Edit",
|
||||
detail: {
|
||||
type: "edit",
|
||||
filePath: "CHANGELOG.md",
|
||||
unifiedDiff: git(cwd, "diff", "--", "CHANGELOG.md"),
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "say",
|
||||
text: [
|
||||
"Reconnects now back off with jitter.",
|
||||
"",
|
||||
"- `getReconnectDelayMs` picks a delay between half and the full backoff, still capped at 30s.",
|
||||
'- `DaemonClient` draws the delay once per attempt, so the "Retrying in" countdown matches the actual retry.',
|
||||
"- Four new tests cover the doubling, the cap, the jitter range, and a quick first retry.",
|
||||
"",
|
||||
"Tests pass. After a relay restart, clients now spread their reconnects across the backoff window instead of arriving together.",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const terminal = await client.createTerminal(cwd, "zsh", undefined, {
|
||||
workspaceId: jitter.id,
|
||||
command: "bash",
|
||||
args: ["--rcfile", path.join(here, "terminal-rc.sh"), "-i"],
|
||||
});
|
||||
const terminalId = terminal.terminal?.id;
|
||||
if (terminalId) {
|
||||
await pause(1500);
|
||||
for (const line of [
|
||||
"git status --short",
|
||||
"cd packages/client",
|
||||
"npx vitest run reconnect --reporter=tree",
|
||||
]) {
|
||||
client.sendTerminalInput(terminalId, { type: "input", data: `${line}\r` });
|
||||
await pause(4000);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`READY port=${daemon.port} home=${daemon.paseoHome}`);
|
||||
await writeFile(
|
||||
path.join(demoRoot, "state.json"),
|
||||
JSON.stringify(
|
||||
{ port: daemon.port, home: daemon.paseoHome, hero: { workspaceId: jitter.id, cwd } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const stop = async () => {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", stop);
|
||||
process.on("SIGINT", stop);
|
||||
void minute;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
# Shell profile for the demo terminal: a Pure-style two-line prompt that fits a
|
||||
# phone-width terminal, and vitest output without the absolute checkout path
|
||||
# or the long timing breakdown. Vitest drops its colors when piped, so the
|
||||
# wrapper puts its usual green checks and grey timings back.
|
||||
export FORCE_COLOR=1
|
||||
__where() { local top; top=$(git rev-parse --show-toplevel 2>/dev/null); printf '~/paseo%s' "${PWD#"$top"}"; }
|
||||
__branch() { git branch --show-current 2>/dev/null; }
|
||||
__dirty() { [ -n "$(git status --porcelain 2>/dev/null)" ] && printf '*'; }
|
||||
PROMPT_COMMAND='printf "\e]0;zsh\a"'
|
||||
PS1='\n\[\e[34m\]$(__where)\[\e[0m\] \[\e[90m\]$(__branch)$(__dirty)\[\e[0m\]\n\[\e[35m\]❯\[\e[0m\] '
|
||||
npx() {
|
||||
command npx "$@" 2>&1 | sed -E \
|
||||
-e '1{/^$/d}' -e '/ RUN /d' -e '/Start at/d' -e 's/ \(transform.*\)//' \
|
||||
-e $'s/✓/\e[32m✓\e[0m/' \
|
||||
-e $'s/ ([0-9]+ms)$/ \e[90m\\1\e[0m/' \
|
||||
-e $'s/ (\\([0-9]+( tests)?\\))/ \e[90m\\1\e[0m/' \
|
||||
-e $'s/([0-9]+ passed)/\e[1;32m\\1\e[0m/'
|
||||
}
|
||||
clear
|
||||
|
Before Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 931 KiB |
|
Before Width: | Height: | Size: 736 KiB |
|
Before Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 701 KiB |
|
Before Width: | Height: | Size: 463 KiB |
|
Before Width: | Height: | Size: 514 KiB |
|
Before Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 537 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 607 KiB |
|
Before Width: | Height: | Size: 1006 KiB |
|
Before Width: | Height: | Size: 760 KiB |
|
Before Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 431 KiB |
|
Before Width: | Height: | Size: 470 KiB |
|
Before Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 454 KiB |
|
Before Width: | Height: | Size: 492 KiB |
|
Before Width: | Height: | Size: 497 KiB |
|
Before Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 298 KiB |
@@ -1,413 +0,0 @@
|
||||
// Renders the App Store and Google Play screenshots from the native captures in
|
||||
// raw/. Every image is resized with Lanczos to the exact pixel size it occupies
|
||||
// on the canvas, and the page renders at device scale 1, so the browser never
|
||||
// resamples a capture.
|
||||
//
|
||||
// node assets/store-screenshots/render.mjs
|
||||
//
|
||||
// SF Pro is not redistributable. On macOS it comes from /System/Library/Fonts;
|
||||
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
import { chromium } from "playwright";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import sharp from "sharp";
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repo = path.resolve(root, "../..");
|
||||
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
|
||||
const output = path.join(root, "output");
|
||||
const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images");
|
||||
|
||||
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
|
||||
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
|
||||
const DEVICES = {
|
||||
iphone: {
|
||||
capture: { width: 1320, height: 2868 },
|
||||
radius: 186,
|
||||
bezel: 22,
|
||||
island: { width: 378, height: 112, top: 33 },
|
||||
},
|
||||
ipad: { capture: { width: 2064, height: 2752 }, radius: 44, bezel: 30, island: null },
|
||||
};
|
||||
|
||||
// Every title wraps to two lines and every subline fits on one; the renderer
|
||||
// fails otherwise, because the device is placed from that fixed text height.
|
||||
//
|
||||
// Each scene names its capture per device, and a device entry may override the
|
||||
// title or subline where the copy depends on the form factor.
|
||||
//
|
||||
// A callout lifts a region of a capture out of the device. `crop` is in native
|
||||
// capture pixels, `zoom` is its size relative to the device screen (capped to
|
||||
// the canvas width), and the callout is centred on the spot the crop comes
|
||||
// from, moved by `shift` native pixels down the screen.
|
||||
const SCENES = [
|
||||
{
|
||||
id: "sidebar",
|
||||
backdrop: "agents",
|
||||
title: "Run many coding agents at once",
|
||||
body: "See which agents are working and which need you.",
|
||||
iphone: { capture: "sidebar" },
|
||||
ipad: { capture: "sidebar" },
|
||||
},
|
||||
{
|
||||
id: "chat",
|
||||
title: "Your full dev environment on your phone",
|
||||
body: "Start agents and follow every edit and command.",
|
||||
iphone: { capture: "chat" },
|
||||
ipad: { capture: "chat", title: "Your full dev environment on your tablet" },
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
title: "Ask an agent to change the app itself",
|
||||
body: "This Deploys sheet is a plugin an agent wrote.",
|
||||
iphone: {
|
||||
capture: "plugin",
|
||||
callout: { from: "plugin-sheet", crop: [0, 1340, 1320, 1400], zoom: 1.04, shift: -1400 },
|
||||
},
|
||||
ipad: {
|
||||
capture: "plugin",
|
||||
body: "This Deploys panel is a plugin an agent wrote.",
|
||||
callout: { from: "plugin-sheet", crop: [670, 1486, 844, 970], zoom: 1.3, shift: -900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
title: "Review the diff before you merge",
|
||||
body: "Every file the agent changed, on its own branch.",
|
||||
iphone: {
|
||||
capture: "diff",
|
||||
callout: { from: "files", crop: [0, 1060, 1320, 870], zoom: 1.26, shift: 110 },
|
||||
},
|
||||
ipad: { capture: "diff" },
|
||||
},
|
||||
{
|
||||
id: "terminal",
|
||||
title: "A real terminal in every workspace",
|
||||
body: "Run the tests yourself, on your own machine.",
|
||||
iphone: {
|
||||
capture: "terminal",
|
||||
callout: { from: "terminal", crop: [0, 950, 1320, 460], zoom: 1.35, shift: 110 },
|
||||
},
|
||||
ipad: {
|
||||
capture: "terminal",
|
||||
callout: { from: "terminal", crop: [596, 640, 1010, 412], zoom: 1.35, shift: 650 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// `textTop` is where the text starts when the device is sized by the canvas
|
||||
// height. A canvas too narrow for that, like a tablet on 9:16, sizes the device
|
||||
// by its width and centres the text and device instead.
|
||||
const STORES = {
|
||||
apple: {
|
||||
device: "iphone",
|
||||
width: 1320,
|
||||
height: 2868,
|
||||
textTop: 230,
|
||||
titleSize: 100,
|
||||
bodySize: 46,
|
||||
textWidth: 1120,
|
||||
},
|
||||
google: {
|
||||
device: "iphone",
|
||||
androidScreenshots: "phoneScreenshots",
|
||||
width: 1440,
|
||||
height: 2560,
|
||||
textTop: 150,
|
||||
titleSize: 96,
|
||||
bodySize: 44,
|
||||
textWidth: 1200,
|
||||
},
|
||||
"apple-ipad": {
|
||||
device: "ipad",
|
||||
width: 2064,
|
||||
height: 2752,
|
||||
textTop: 200,
|
||||
titleSize: 124,
|
||||
bodySize: 56,
|
||||
textWidth: 1400,
|
||||
},
|
||||
"google-tablet": {
|
||||
device: "ipad",
|
||||
androidScreenshots: "tenInchScreenshots",
|
||||
width: 1800,
|
||||
height: 3200,
|
||||
textTop: 200,
|
||||
titleSize: 136,
|
||||
bodySize: 60,
|
||||
textWidth: 1560,
|
||||
},
|
||||
};
|
||||
|
||||
const TITLE_LINE = 1.06;
|
||||
const BODY_LINE = 1.3;
|
||||
const BODY_GAP = 30;
|
||||
// The device starts this far below the text and runs BLEED off the bottom.
|
||||
const GAP = 140;
|
||||
const BLEED = 40;
|
||||
const MARGIN = 56;
|
||||
const COLORS = { canvas: "#101615", title: "#ffffff", body: "rgba(255, 255, 255, 0.58)" };
|
||||
|
||||
function findFont() {
|
||||
const dirs = [process.env.SF_FONT_DIR, "/System/Library/Fonts", "/Library/Fonts"].filter(Boolean);
|
||||
for (const dir of dirs) {
|
||||
const file = path.join(dir, "SFNS.ttf");
|
||||
if (existsSync(file)) return file;
|
||||
}
|
||||
throw new Error("SF Pro not found. Set SF_FONT_DIR to a folder containing SFNS.ttf from macOS.");
|
||||
}
|
||||
|
||||
// The device fills the canvas from GAP below the text to just past the bottom
|
||||
// edge, so a taller canvas gets a larger device rather than a larger gap. When
|
||||
// that would be wider than the canvas, the device takes the full width and the
|
||||
// text and the whole device sit centred, still GAP apart.
|
||||
function deviceLayout(store, device) {
|
||||
const { capture, bezel } = device;
|
||||
const textHeight = 2 * store.titleSize * TITLE_LINE + BODY_GAP + store.bodySize * BODY_LINE;
|
||||
const tallest = store.height + BLEED - (store.textTop + textHeight + GAP) - 2 * bezel;
|
||||
const widest = store.width - 2 * MARGIN - 2 * bezel;
|
||||
const screenWidth = Math.min(Math.round((tallest * capture.width) / capture.height), widest);
|
||||
const screenHeight = Math.round((screenWidth * capture.height) / capture.width);
|
||||
const scale = screenWidth / capture.width;
|
||||
const width = screenWidth + 2 * bezel;
|
||||
const height = screenHeight + 2 * bezel;
|
||||
const textTop =
|
||||
screenWidth < widest
|
||||
? store.height + BLEED - height - GAP - textHeight
|
||||
: (store.height - height - GAP - textHeight) / 2;
|
||||
const island = device.island && {
|
||||
width: Math.round(device.island.width * scale),
|
||||
height: Math.round(device.island.height * scale),
|
||||
top: Math.round(device.island.top * scale),
|
||||
};
|
||||
return {
|
||||
textTop: Math.round(textTop),
|
||||
left: Math.round((store.width - width) / 2),
|
||||
top: Math.round(textTop + textHeight + GAP),
|
||||
width,
|
||||
height,
|
||||
bezel,
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
scale,
|
||||
radius: Math.round(device.radius * scale),
|
||||
island,
|
||||
};
|
||||
}
|
||||
|
||||
function calloutLayout(callout, frame, store) {
|
||||
const [cropX, cropY, cropWidth, cropHeight] = callout.crop;
|
||||
const scale = Math.min(frame.scale * callout.zoom, (store.width - 2 * MARGIN) / cropWidth);
|
||||
const width = Math.round(cropWidth * scale);
|
||||
const height = Math.round(cropHeight * scale);
|
||||
const centreX = frame.left + frame.bezel + (cropX + cropWidth / 2) * frame.scale;
|
||||
const centreY = frame.top + frame.bezel + (cropY + cropHeight / 2 + callout.shift) * frame.scale;
|
||||
const clamp = (value, max) => Math.round(Math.min(Math.max(value, MARGIN), max - MARGIN));
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: clamp(centreX - width / 2, store.width - width),
|
||||
top: clamp(centreY - height / 2, store.height - height),
|
||||
};
|
||||
}
|
||||
|
||||
async function resize(source, crop, width, height, file) {
|
||||
let image = sharp(source);
|
||||
if (crop) image = image.extract({ left: crop[0], top: crop[1], width: crop[2], height: crop[3] });
|
||||
await image.resize(width, height, { kernel: "lanczos3" }).png().toFile(file);
|
||||
return pathToFileURL(file).href;
|
||||
}
|
||||
|
||||
// Agent marks scattered behind the first panel, from the homepage's featured
|
||||
// agents. Each entry is [mark, centre x, centre y, size, rotation], with the
|
||||
// centre as a fraction of the canvas and the size as a fraction of its width.
|
||||
const AGENT_MARKS = [
|
||||
["ClaudeCodeIcon", 0.11, 0.075, 0.12, -14],
|
||||
["CodexIcon", 0.89, 0.1, 0.13, 12],
|
||||
["PiIcon", 0.07, 0.34, 0.1, 10],
|
||||
["OmpIcon", 0.93, 0.37, 0.11, -10],
|
||||
["OpenCodeIcon", 0.08, 0.6, 0.11, -8],
|
||||
["CursorIcon", 0.92, 0.63, 0.1, 16],
|
||||
["MuseCodeIcon", 0.07, 0.85, 0.11, 14],
|
||||
["AntigravityIcon", 0.92, 0.88, 0.12, -12],
|
||||
];
|
||||
|
||||
async function loadAgentMarks(work) {
|
||||
const bundle = path.join(work, "agent-icons.mjs");
|
||||
await build({
|
||||
entryPoints: [path.join(repo, "packages/website/src/components/agent-icons.tsx")],
|
||||
outfile: bundle,
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
jsx: "automatic",
|
||||
});
|
||||
const icons = await import(pathToFileURL(bundle).href);
|
||||
return Object.fromEntries(
|
||||
AGENT_MARKS.map(([name]) => [name, renderToStaticMarkup(createElement(icons[name]))]),
|
||||
);
|
||||
}
|
||||
|
||||
function backdrop(store, marks) {
|
||||
return AGENT_MARKS.map(([name, x, y, size, rotation]) => {
|
||||
const side = Math.round(size * store.width);
|
||||
const left = Math.round(x * store.width - side / 2);
|
||||
const top = Math.round(y * store.height - side / 2);
|
||||
return `<div class="mark" style="left:${left}px;top:${top}px;width:${side}px;height:${side}px;transform:rotate(${rotation}deg)">${marks[name]}</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function page({ store, shot, frame, screen, callout, calloutImage, font, marks }) {
|
||||
const behind = shot.backdrop === "agents" ? backdrop(store, marks) : "";
|
||||
const { island } = frame;
|
||||
const notch = island
|
||||
? `<div class="island" style="top:${island.top}px;width:${island.width}px;height:${island.height}px;margin-left:-${island.width / 2}px;border-radius:${island.height / 2}px"></div>`
|
||||
: "";
|
||||
const lifted = callout
|
||||
? `<img class="callout" src="${calloutImage}" style="left:${callout.left}px;top:${callout.top}px;width:${callout.width}px;height:${callout.height}px">`
|
||||
: "";
|
||||
return `<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
@font-face { font-family: "SF Pro"; src: url("${pathToFileURL(font).href}"); font-weight: 1 1000; }
|
||||
* { margin: 0; box-sizing: border-box; }
|
||||
html, body { width: ${store.width}px; height: ${store.height}px; overflow: hidden; background: ${COLORS.canvas}; }
|
||||
body { position: relative; font-family: "SF Pro"; -webkit-font-smoothing: antialiased; }
|
||||
.text { position: absolute; top: ${frame.textTop}px; left: 0; right: 0; margin: 0 auto; width: ${store.textWidth}px; text-align: center; }
|
||||
.mark { position: absolute; color: #fff; opacity: 0.09; filter: grayscale(1) brightness(0) invert(1); }
|
||||
.mark svg { display: block; width: 100%; height: 100%; }
|
||||
h1 { color: ${COLORS.title}; font-size: ${store.titleSize}px; font-weight: 600; line-height: ${TITLE_LINE}; letter-spacing: -0.022em; text-wrap: balance; }
|
||||
p { margin-top: ${BODY_GAP}px; color: ${COLORS.body}; font-size: ${store.bodySize}px; font-weight: 400; line-height: ${BODY_LINE}; letter-spacing: -0.005em; text-wrap: balance; }
|
||||
.device { position: absolute; left: ${frame.left}px; top: ${frame.top}px; width: ${frame.width}px; height: ${frame.height}px; padding: ${frame.bezel}px; border-radius: ${frame.radius + frame.bezel}px; background: #040505; box-shadow: 0 0 0 3px #343b39, 0 0 0 5px #161b1a, 0 60px 140px rgba(0, 0, 0, 0.55); }
|
||||
.screen { position: relative; width: ${frame.screenWidth}px; height: ${frame.screenHeight}px; border-radius: ${frame.radius}px; overflow: hidden; }
|
||||
.screen img { display: block; width: ${frame.screenWidth}px; height: ${frame.screenHeight}px; }
|
||||
.island { position: absolute; left: 50%; background: #000; }
|
||||
.callout { position: absolute; display: block; border-radius: 36px; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.14), 0 40px 100px rgba(0, 0, 0, 0.6); }
|
||||
</style>
|
||||
${behind}
|
||||
<div class="text"><h1>${shot.title}</h1><p>${shot.body}</p></div>
|
||||
<div class="device"><div class="screen"><img src="${screen}">${notch}</div></div>
|
||||
${lifted}`;
|
||||
}
|
||||
|
||||
async function renderStore(browser, name, store, font, marks, work) {
|
||||
const dir = path.join(output, name);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const view = await browser.newPage({
|
||||
viewport: { width: store.width, height: store.height },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const device = DEVICES[store.device];
|
||||
const frame = deviceLayout(store, device);
|
||||
const files = [];
|
||||
for (const [index, scene] of SCENES.entries()) {
|
||||
const stem = `${name}-${scene.id}`;
|
||||
const shot = { ...scene, ...scene[store.device] };
|
||||
const screen = await resize(
|
||||
raw(store.device, shot.capture),
|
||||
null,
|
||||
frame.screenWidth,
|
||||
frame.screenHeight,
|
||||
path.join(work, `${stem}-screen.png`),
|
||||
);
|
||||
const callout = shot.callout ? calloutLayout(shot.callout, frame, store) : null;
|
||||
const calloutImage = callout
|
||||
? await resize(
|
||||
raw(store.device, shot.callout.from),
|
||||
shot.callout.crop,
|
||||
callout.width,
|
||||
callout.height,
|
||||
path.join(work, `${stem}-callout.png`),
|
||||
)
|
||||
: null;
|
||||
const html = path.join(work, `${stem}.html`);
|
||||
await writeFile(html, page({ store, shot, frame, screen, callout, calloutImage, font, marks }));
|
||||
await view.goto(pathToFileURL(html).href);
|
||||
await view.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await Promise.all(Array.from(document.images, (image) => image.decode()));
|
||||
});
|
||||
await expectLines(view, "h1", 2, `${stem} title`);
|
||||
await expectLines(view, "p", 1, `${stem} subline`);
|
||||
const file = path.join(dir, `${String(index + 1).padStart(2, "0")}-${scene.id}.png`);
|
||||
await sharp(await view.screenshot())
|
||||
.removeAlpha()
|
||||
.png()
|
||||
.toFile(file);
|
||||
files.push(file);
|
||||
console.log(path.relative(repo, file));
|
||||
}
|
||||
await view.close();
|
||||
return files;
|
||||
}
|
||||
|
||||
async function expectLines(view, selector, expected, label) {
|
||||
const lines = await view.$eval(selector, (element) =>
|
||||
Math.round(element.clientHeight / parseFloat(getComputedStyle(element).lineHeight)),
|
||||
);
|
||||
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
|
||||
}
|
||||
|
||||
// Google Play and F-Droid read their screenshots from the Android metadata,
|
||||
// which holds links to these renders. Rewriting the links on every render keeps
|
||||
// added, removed and renamed panels in step with the App Store.
|
||||
async function linkAndroidScreenshots(folder, files) {
|
||||
const dir = path.join(androidImages, folder);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
await mkdir(dir, { recursive: true });
|
||||
for (const [index, file] of files.entries()) {
|
||||
await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`));
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPreview(rows) {
|
||||
const tile = 440;
|
||||
const gap = 40;
|
||||
const resized = [];
|
||||
let top = gap;
|
||||
let width = 0;
|
||||
for (const files of rows) {
|
||||
let left = gap;
|
||||
let rowHeight = 0;
|
||||
for (const file of files) {
|
||||
const meta = await sharp(file).metadata();
|
||||
const height = Math.round((meta.height * tile) / meta.width);
|
||||
const input = await sharp(file).resize(tile, height, { kernel: "lanczos3" }).toBuffer();
|
||||
resized.push({ input, left, top });
|
||||
left += tile + gap;
|
||||
rowHeight = Math.max(rowHeight, height);
|
||||
}
|
||||
width = Math.max(width, left);
|
||||
top += rowHeight + gap;
|
||||
}
|
||||
const file = path.join(output, "preview.png");
|
||||
await sharp({ create: { width, height: top, channels: 3, background: "#070a09" } })
|
||||
.composite(resized)
|
||||
.png()
|
||||
.toFile(file);
|
||||
console.log(path.relative(repo, file));
|
||||
}
|
||||
|
||||
const font = findFont();
|
||||
const work = await mkdtemp(path.join(os.tmpdir(), "paseo-store-"));
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const marks = await loadAgentMarks(work);
|
||||
const rows = [];
|
||||
for (const [name, store] of Object.entries(STORES)) {
|
||||
const files = await renderStore(browser, name, store, font, marks, work);
|
||||
if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files);
|
||||
rows.push(files);
|
||||
}
|
||||
await renderPreview(rows);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -215,8 +215,7 @@ fastlane/metadata/android/
|
||||
│ ├── images/
|
||||
│ │ ├── icon.png (512x512)
|
||||
│ │ ├── featureGraphic.png (1024x500)
|
||||
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
||||
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
||||
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
|
||||
│ └── changelogs/ (generated — see below)
|
||||
├── ja/
|
||||
└── zh-CN/
|
||||
@@ -224,8 +223,6 @@ fastlane/metadata/android/
|
||||
|
||||
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
||||
|
||||
Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots).
|
||||
|
||||
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
||||
|
||||
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -382,6 +382,15 @@ grace. During daemon startup, plugin sessions may connect while application WebS
|
||||
paused; the daemon accepts clients only after configured plugins have settled and the initial
|
||||
catalog is complete.
|
||||
|
||||
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
|
||||
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
|
||||
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
|
||||
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
|
||||
passing the bundle string still costs memory and time once per client lifetime.
|
||||
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
|
||||
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
|
||||
in-memory bundles usable, and a later catalog retries persistence.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
|
||||
@@ -305,8 +305,8 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
|
||||
|
||||
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
||||
|
||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials). A Fastlane lane then replaces the store screenshots.
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane uploads the store screenshots and submits that same build for App Store review.
|
||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
|
||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
||||
|
||||
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
|
||||
@@ -317,16 +317,6 @@ Stable iOS releases use one binary for two Apple review paths: Beta App Review f
|
||||
|
||||
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
|
||||
|
||||
### Store screenshots
|
||||
|
||||
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
|
||||
|
||||
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
|
||||
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches.
|
||||
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
|
||||
|
||||
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
|
||||
|
||||
### Watching mobile builds from the terminal
|
||||
|
||||
Use the EAS CLI from `packages/app/`:
|
||||
@@ -369,7 +359,6 @@ For the `Release Mobile` EAS workflow, these jobs must pass:
|
||||
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
||||
- `build_android` — Android store binary built
|
||||
- `submit_android` — Android binary submitted to the Play Store
|
||||
- `upload_android_screenshots` — Google Play screenshots replaced from the repo
|
||||
|
||||
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,10 +0,0 @@
|
||||
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
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,10 +0,0 @@
|
||||
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
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,10 +0,0 @@
|
||||
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
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
|
||||
../../../../../../packages/website/public/phone-1.png
|
||||
@@ -1 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google/02-chat.png
|
||||
../../../../../../packages/website/public/phone-2.png
|
||||
@@ -1 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google/03-plugins.png
|
||||
../../../../../../packages/website/public/phone-3.png
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../../assets/store-screenshots/output/google/04-review.png
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../../assets/store-screenshots/output/google/05-terminal.png
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37590,7 +37590,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39793,12 +39793,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/server": "0.14.0",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/server": "0.14.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -40045,10 +40045,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/relay": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -40060,7 +40060,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40304,7 +40304,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40350,7 +40350,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -40379,15 +40379,15 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40397,8 +40397,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40420,7 +40420,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40447,7 +40447,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
@@ -40668,16 +40668,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/highlight": "0.14.0",
|
||||
"@getpaseo/plugin": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/relay": "0.14.0",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/highlight": "0.14.0-beta.1",
|
||||
"@getpaseo/plugin": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41598,7 +41598,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43208,7 +43208,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -42,20 +42,6 @@ jobs:
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
profile: production
|
||||
|
||||
upload_android_screenshots:
|
||||
name: Upload Google Play screenshots
|
||||
needs: [build_android, submit_android]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Upload screenshots
|
||||
run: |
|
||||
export ANDROID_VERSION_CODE="${{ needs.build_android.outputs.app_build_version }}"
|
||||
bundle exec fastlane android upload_screenshots
|
||||
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [build_ios, distribute_ios]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
withTimelinePlugin,
|
||||
viewInlineThinkingExample,
|
||||
@@ -6,6 +7,8 @@ import {
|
||||
interactWithStreamingCard,
|
||||
expectWholeCompletedCard,
|
||||
expectBothConsecutiveTools,
|
||||
trackPluginChatRecovery,
|
||||
reconnectPluginChat,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
|
||||
for (const width of [1100, 390]) {
|
||||
@@ -13,13 +16,24 @@ for (const width of [1100, 390]) {
|
||||
page,
|
||||
}, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await withTimelinePlugin(page, info, "assistant", async (agent) => {
|
||||
await requestPluginTimeline(agent);
|
||||
await interactWithStreamingCard(page);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expectWholeCompletedCard(page);
|
||||
// Local state is checked during growth; the viewport remounts on the history handoff.
|
||||
const recovery = trackPluginChatRecovery(page);
|
||||
gate.holdTimelineResponses(agent.agentId);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await recovery.expectCatalogWithoutRetransmission();
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expectWholeCompletedCard(page);
|
||||
await recovery.expectRecoveredWithoutRetransmission();
|
||||
recovery.reset();
|
||||
await reconnectPluginChat(page, gate);
|
||||
await recovery.expectRecoveredWithoutRetransmission();
|
||||
await expectWholeCompletedCard(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -203,47 +203,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[];
|
||||
@@ -357,20 +316,6 @@ test("opening a turn keeps the received message above it clipped on every frame"
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Native turn reading-position diagnostic
|
||||
|
||||
Reproduction for [#8057](https://github.com/getpaseo/paseo/issues/8057).
|
||||
This uses the production turn presentation and native stream strategy with fixed-height
|
||||
message rows. It isolates viewport behavior; it does not exercise a provider, the full
|
||||
conversation screen, iOS, or the precise automatic-collapse reading-row report.
|
||||
|
||||
Build the Android development client from this checkout. In a disposable checkout,
|
||||
temporarily replace `packages/app/index.ts` with:
|
||||
|
||||
```tsx
|
||||
import "./src/polyfills/install-early";
|
||||
import "./src/styles/unistyles";
|
||||
import { registerRootComponent } from "expo";
|
||||
import Diagnostic from "./e2e/mobile/turn-scroll/native";
|
||||
registerRootComponent(Diagnostic);
|
||||
```
|
||||
|
||||
Start Metro from `packages/app` on an available port:
|
||||
|
||||
```sh
|
||||
APP_VARIANT=development npx expo start --lan --port 8097
|
||||
```
|
||||
|
||||
With an Android emulator running, forward that port with `adb reverse tcp:8097 tcp:8097`.
|
||||
From the repository root, run:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/turn-scroll/android.mjs turn-scroll http://127.0.0.1:8097
|
||||
```
|
||||
|
||||
The test expands the middle summary, brings it back into view, collapses it, and
|
||||
asserts its native window Y stays within two points. Baseline fails with approximately
|
||||
`expand=-450.3, collapse=450.3`. Measurements use `measureInWindow`, not accessibility
|
||||
bounds of off-screen rows.
|
||||
|
||||
The separate buttons expose the latest turn's running-to-finished projection with a
|
||||
five-second delay. They measure its summary before and after completion, rather than
|
||||
claiming to follow a disappearing reading row.
|
||||
|
||||
Restore `index.ts`, close the device session, stop Metro, and stop the emulator after use.
|
||||
This is an intentionally failing diagnostic, not a production fix or a CI unit test.
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , session, metroUrl] = process.argv;
|
||||
assert(session && metroUrl, "Pass a session name and the Metro URL serving native.tsx");
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
ad(
|
||||
"open",
|
||||
"sh.paseo.debug",
|
||||
`exp+voice-mobile://expo-development-client/?url=${encodeURIComponent(metroUrl)}`,
|
||||
"--platform",
|
||||
"android",
|
||||
"--relaunch",
|
||||
);
|
||||
ad("wait", "text", "Ready");
|
||||
ad("press", 'id="host-7"', "--settle");
|
||||
ad("wait", "text", "expand host-7: before=");
|
||||
const expansion = ad("get", "text", 'id="result"');
|
||||
console.log(expansion.trim());
|
||||
const expandedDelta = Number(/delta=([\d.-]+)/.exec(expansion)?.[1]);
|
||||
ad("scroll", "up", "--until", 'id="host-7"');
|
||||
ad("wait", "stable", "1000", "10000");
|
||||
ad("press", 'id="host-7"', "--settle");
|
||||
ad("wait", "text", "collapse host-7: before=");
|
||||
const collapse = ad("get", "text", 'id="result"');
|
||||
console.log(collapse.trim());
|
||||
const collapsedDelta = Number(/delta=([\d.-]+)/.exec(collapse)?.[1]);
|
||||
assert(
|
||||
Number.isFinite(expandedDelta) && Number.isFinite(collapsedDelta),
|
||||
"Both native measurements must complete",
|
||||
);
|
||||
assert(
|
||||
Math.abs(expandedDelta) <= 2 && Math.abs(collapsedDelta) <= 2,
|
||||
`The summary must stay under the reader: expand=${expandedDelta}, collapse=${collapsedDelta}`,
|
||||
);
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Button, Pressable, Text, View, type ViewStyle } from "react-native";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||
import { createNativeStreamStrategy } from "@/agent-stream/strategy-native";
|
||||
import { createStreamPresentation } from "@/agent-stream/presentation";
|
||||
import type { StreamViewportHandle } from "@/agent-stream/strategy";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
const frameStyle: ViewStyle = { flex: 1, paddingTop: 45, paddingBottom: 30 };
|
||||
const listStyle: ViewStyle = { flex: 1 };
|
||||
const resultStyle = { color: "#111", height: 50 };
|
||||
const textStyle = { color: "#111", padding: 8 };
|
||||
const rowStyles: ViewStyle[] = [false, true].flatMap((work) =>
|
||||
[false, true].map((host) => ({
|
||||
height: work ? 90 : 60,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "#bbb",
|
||||
backgroundColor: host ? "#ccddff" : "#fff",
|
||||
justifyContent: "center",
|
||||
})),
|
||||
);
|
||||
function Row({
|
||||
item,
|
||||
expanded,
|
||||
isHost,
|
||||
attach,
|
||||
toggle,
|
||||
}: {
|
||||
item: StreamItem;
|
||||
expanded: boolean;
|
||||
isHost: boolean;
|
||||
attach(id: string, view: View | null): void;
|
||||
toggle(id: string): void;
|
||||
}) {
|
||||
const ref = useCallback((view: View | null) => attach(item.id, view), [attach, item.id]);
|
||||
const onPress = useCallback(() => toggle(item.id), [toggle, item.id]);
|
||||
const style = rowStyles[(item.id.startsWith("work") ? 2 : 0) + (isHost ? 1 : 0)];
|
||||
const stateLabel = expanded ? " Collapse" : " Expand";
|
||||
return (
|
||||
<View ref={ref} collapsable={false} style={style}>
|
||||
<Pressable testID={item.id} onPress={onPress}>
|
||||
<Text style={textStyle}>
|
||||
{item.id}
|
||||
{isHost ? stateLabel : ""}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
function measureY(view: View | undefined): Promise<number> {
|
||||
if (!view) return Promise.reject(new Error("Diagnostic row is not mounted"));
|
||||
return new Promise((resolve) => view.measureInWindow((_x, y) => resolve(y)));
|
||||
}
|
||||
function settleLayout(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
const strategy = createNativeStreamStrategy();
|
||||
const tail: StreamItem[] = Array.from({ length: 10 }, (_, n) => {
|
||||
const turnId = `turn-${n}`;
|
||||
const timestamp = new Date(2026, 0, 1, 0, n);
|
||||
return [
|
||||
{ kind: "user_message" as const, id: `host-${n}`, turnId, timestamp, text: `Prompt ${n}` },
|
||||
...Array.from({ length: 5 }, (_work, i) => ({
|
||||
kind: "assistant_message" as const,
|
||||
id: `work-${n}-${i}`,
|
||||
turnId,
|
||||
timestamp,
|
||||
text: `Work ${n}-${i}`,
|
||||
})),
|
||||
{ kind: "assistant_message" as const, id: `reply-${n}`, turnId, timestamp, text: `Reply ${n}` },
|
||||
];
|
||||
}).flat();
|
||||
|
||||
function Transcript() {
|
||||
const present = useMemo(createStreamPresentation, []);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
|
||||
const [running, setRunning] = useState(false);
|
||||
const [report, setReport] = useState("Ready");
|
||||
const rows = useRef(new Map<string, View>());
|
||||
const presentation = present({
|
||||
tail,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
display: "collapseTurns",
|
||||
expandedTurnHostIds: expanded,
|
||||
isTurnActive: running,
|
||||
activeTurnId: running ? "turn-9" : null,
|
||||
});
|
||||
const historyMounted = strategy.orderTail(presentation.tail);
|
||||
const sample = useCallback(async (id: string, next: () => void, label = "complete") => {
|
||||
const before = await measureY(rows.current.get(id));
|
||||
next();
|
||||
await settleLayout();
|
||||
const after = await measureY(rows.current.get(id));
|
||||
setReport(
|
||||
`${label} ${id}: before=${before.toFixed(1)} after=${after.toFixed(1)} delta=${(after - before).toFixed(1)}`,
|
||||
);
|
||||
}, []);
|
||||
const attach = useCallback((id: string, view: View | null) => {
|
||||
if (view) rows.current.set(id, view);
|
||||
else rows.current.delete(id);
|
||||
}, []);
|
||||
const toggle = useCallback(
|
||||
(id: string) => {
|
||||
const turn = presentation.turns.turnsByHostId.get(id);
|
||||
if (!turn) return;
|
||||
sample(
|
||||
id,
|
||||
() => {
|
||||
viewportRef.current?.holdReadingPosition();
|
||||
setExpanded((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
turn.expanded ? "collapse" : "expand",
|
||||
);
|
||||
},
|
||||
[presentation.turns.turnsByHostId, sample],
|
||||
);
|
||||
const renderRow = (item: StreamItem) => {
|
||||
const turn = presentation.turns.turnsByHostId.get(item.id);
|
||||
return (
|
||||
<Row
|
||||
item={item}
|
||||
isHost={!!turn}
|
||||
expanded={!!turn?.expanded}
|
||||
attach={attach}
|
||||
toggle={toggle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const start = useCallback(() => setRunning(true), []);
|
||||
const finish = useCallback(
|
||||
() => setTimeout(() => sample("host-9", () => setRunning(false)), 5000),
|
||||
[sample],
|
||||
);
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<Text testID="result" style={resultStyle}>
|
||||
{report}
|
||||
</Text>
|
||||
<Button title="Start running turn" onPress={start} />
|
||||
<Button title="Finish in 5 seconds" onPress={finish} />
|
||||
<View style={listStyle}>
|
||||
{strategy.render({
|
||||
agentId: "diagnostic",
|
||||
segments: { historyVirtualized: [], historyMounted, liveHead: [] },
|
||||
liveHeadRowRevision: expanded,
|
||||
historyRowRevision: {
|
||||
contentById: new Set(),
|
||||
displayStateById: new Set([...expanded, ...presentation.turns.turnsByHostId.keys()]),
|
||||
globalDisplayState: running,
|
||||
},
|
||||
readingRowReplacements: presentation.readingRowReplacements,
|
||||
boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false },
|
||||
renderers: {
|
||||
renderHistoryVirtualizedRow: renderRow,
|
||||
renderHistoryMountedRow: renderRow,
|
||||
renderLiveHeadRow: renderRow,
|
||||
renderLiveAuxiliary: () => null,
|
||||
},
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: () => false,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: { flex: 1 },
|
||||
baseListContentContainerStyle: {},
|
||||
forwardListContentContainerStyle: {},
|
||||
contentMaxWidth: 400,
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
export default function Diagnostic() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<Transcript />
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,64 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
import { expectReconnectingToastVisible } from "./workspace-ui";
|
||||
|
||||
const PLUGIN_ID = "timeline-transform-e2e";
|
||||
|
||||
/** Observe the app's real socket, including fresh sockets after a reload. */
|
||||
export function trackPluginChatRecovery(page: Page) {
|
||||
const messages: {
|
||||
type: string;
|
||||
payload?: { plugins?: { id: string; clientBundle?: string }[] };
|
||||
}[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
const collect = ({ payload }: { payload: string | Buffer }) => {
|
||||
if (typeof payload !== "string") return;
|
||||
const frame = JSON.parse(payload);
|
||||
if (frame.type === "session") messages.push(frame.message);
|
||||
};
|
||||
socket.on("framesent", collect);
|
||||
socket.on("framereceived", collect);
|
||||
});
|
||||
return {
|
||||
reset() {
|
||||
messages.length = 0;
|
||||
},
|
||||
async expectCatalogWithoutRetransmission() {
|
||||
await expect
|
||||
.poll(() =>
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "plugin.catalog.get.response" &&
|
||||
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
const entry = messages
|
||||
.find((message) => message.type === "plugin.catalog.get.response")
|
||||
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry).not.toHaveProperty("clientBundle");
|
||||
},
|
||||
async expectRecoveredWithoutRetransmission() {
|
||||
await this.expectCatalogWithoutRetransmission();
|
||||
await expect
|
||||
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
|
||||
.toBe(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function reconnectPluginChat(
|
||||
page: Page,
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
) {
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
gate.restore();
|
||||
}
|
||||
export const ASSISTANT_TEXT =
|
||||
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
|
||||
|
||||
|
||||
@@ -1,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__ };
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,31 +1,7 @@
|
||||
require "fileutils"
|
||||
require "tmpdir"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = "sh.paseo"
|
||||
|
||||
# Store screenshots are rendered into assets/store-screenshots/output; see the
|
||||
# README there. Google Play and F-Droid both read fastlane/metadata/android at
|
||||
# the repo root, whose screenshot files link to those renders.
|
||||
REPO_ROOT = File.expand_path("../../..", __dir__)
|
||||
STORE_SCREENSHOTS = File.join(REPO_ROOT, "assets/store-screenshots/output")
|
||||
ANDROID_METADATA = File.join(REPO_ROOT, "fastlane/metadata/android")
|
||||
|
||||
# deliver reads screenshots from one folder per language and picks the device
|
||||
# slot from each image's size, so both Apple sets go into en-US together.
|
||||
def stage_app_store_screenshots
|
||||
root = Dir.mktmpdir("app-store-screenshots")
|
||||
locale = File.join(root, "en-US")
|
||||
FileUtils.mkdir_p(locale)
|
||||
{ "apple" => "iphone", "apple-ipad" => "ipad" }.each do |set, device|
|
||||
Dir.glob(File.join(STORE_SCREENSHOTS, set, "*.png")).sort.each do |file|
|
||||
FileUtils.cp(file, File.join(locale, "#{device}-#{File.basename(file)}"))
|
||||
end
|
||||
end
|
||||
root
|
||||
end
|
||||
|
||||
platform :ios do
|
||||
desc "Submit the latest TestFlight build to App Store Review"
|
||||
lane :submit_review do
|
||||
@@ -111,8 +87,7 @@ platform :ios do
|
||||
},
|
||||
force: true,
|
||||
skip_binary_upload: true,
|
||||
screenshots_path: stage_app_store_screenshots,
|
||||
overwrite_screenshots: true,
|
||||
skip_screenshots: true,
|
||||
precheck_include_in_app_purchases: false,
|
||||
submission_information: {
|
||||
add_id_info_uses_idfa: false,
|
||||
@@ -121,25 +96,3 @@ platform :ios do
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
platform :android do
|
||||
desc "Replace the Google Play store screenshots with the ones in the repo"
|
||||
lane :upload_screenshots do
|
||||
# supply attaches listing changes to a release on the production track.
|
||||
# The release workflow passes the version code it just submitted; without
|
||||
# it, supply uses the track's only release.
|
||||
version_code = ENV["ANDROID_VERSION_CODE"].to_s.strip
|
||||
supply(
|
||||
package_name: APP_IDENTIFIER,
|
||||
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
|
||||
metadata_path: ANDROID_METADATA,
|
||||
version_code: version_code.empty? ? nil : version_code.to_i,
|
||||
skip_upload_apk: true,
|
||||
skip_upload_aab: true,
|
||||
skip_upload_metadata: true,
|
||||
skip_upload_changelogs: true,
|
||||
skip_upload_images: true,
|
||||
sync_image_upload: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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";
|
||||
@@ -369,8 +364,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 +431,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
useEffect(() => {
|
||||
setIsNearBottom(true);
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
setExpandedToolCallGroupIds(new Set());
|
||||
setExpandedTurnHostIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
@@ -711,7 +708,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 +815,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 +847,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentMessage={data.agentMessage}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
standalone={options.standalone}
|
||||
maxDetailHeight={options.maxDetailHeight}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -857,8 +863,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
status={data.status}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
standalone={options.standalone}
|
||||
maxDetailHeight={options.maxDetailHeight}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -881,7 +886,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}
|
||||
@@ -901,14 +906,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
status={call.status}
|
||||
isLastInSequence={index === group.run.calls.length - 1}
|
||||
defaultExpanded={false}
|
||||
standalone={false}
|
||||
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 +922,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
},
|
||||
[
|
||||
expandedToolCallGroups,
|
||||
expandedToolCallGroupIds,
|
||||
renderThoughtItem,
|
||||
setInlineDetailsExpanded,
|
||||
getToolCallGroup,
|
||||
@@ -1169,18 +1174,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 +1207,30 @@ 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({
|
||||
readingRowReplacements: presentation.readingRowReplacements,
|
||||
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 +1403,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 +1413,6 @@ function ThoughtSlot({
|
||||
status,
|
||||
isLastInSequence,
|
||||
defaultExpanded,
|
||||
standalone,
|
||||
}: ThoughtSlotProps) {
|
||||
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
|
||||
return (
|
||||
@@ -1426,7 +1424,6 @@ function ThoughtSlot({
|
||||
status={status === "ready" ? "completed" : "executing"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
defaultExpanded={defaultExpanded}
|
||||
standalone={standalone}
|
||||
forceInline={defaultExpanded}
|
||||
/>
|
||||
);
|
||||
@@ -1441,9 +1438,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);
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
|
||||