mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 04:45:11 +00:00
Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa6df8a959 | ||
|
|
ec179aa01e | ||
|
|
0563654cb0 | ||
|
|
6ead9a9e0b | ||
|
|
4d740fd4dd | ||
|
|
ddc45fe1c1 | ||
|
|
4ed83a0ef0 | ||
|
|
969b2938e9 | ||
|
|
b759977818 | ||
|
|
35acb5a914 | ||
|
|
0869f6df33 | ||
|
|
18b19f1e78 | ||
|
|
37c99c12b1 | ||
|
|
bf3d6b5fe0 | ||
|
|
4c072573fe | ||
|
|
ee4c26f48e | ||
|
|
19d791777e | ||
|
|
b2a83d335f | ||
|
|
c523b3ad9b | ||
|
|
4858e62270 | ||
|
|
d6efcc453b | ||
|
|
2e90865134 | ||
|
|
603c52b294 | ||
|
|
737792ad7c | ||
|
|
3edd502aac | ||
|
|
bd3d86749c | ||
|
|
b152308340 | ||
|
|
2bd55c5ff7 | ||
|
|
f7a442f4d4 | ||
|
|
1f79e07feb | ||
|
|
c7f55fd13f | ||
|
|
7349b26b56 | ||
|
|
0d9cf2b982 | ||
|
|
dc4379ee01 | ||
|
|
8e0a18f595 | ||
|
|
b927be1bc7 | ||
|
|
e755886c4f | ||
|
|
857808f7c9 | ||
|
|
ad5e76b133 | ||
|
|
d7e8099288 | ||
|
|
27bbf5355b | ||
|
|
3536689ffb | ||
|
|
322c542eaf | ||
|
|
fa8dcb372a | ||
|
|
59ab8b8c1f | ||
|
|
75a1ccdcee | ||
|
|
4b67d035ce | ||
|
|
5afbd0d7d3 | ||
|
|
d30aa16beb | ||
|
|
a6a37b568d | ||
|
|
93d21e5612 | ||
|
|
caae991a0f | ||
|
|
046ac7b339 | ||
|
|
6213df73dc | ||
|
|
02d3a05d2b | ||
|
|
175f36d64a | ||
|
|
dd2281d82c | ||
|
|
2a9395e2d1 | ||
|
|
85ce1aa828 | ||
|
|
d13438ba50 | ||
|
|
cf9eaa8d29 | ||
|
|
ec52097924 | ||
|
|
a30b784eb2 | ||
|
|
af6ca46ae3 | ||
|
|
8dc979ec2f | ||
|
|
70aeb7b45b | ||
|
|
8b3b2a9ea3 | ||
|
|
b6952995be | ||
|
|
c558af91eb | ||
|
|
7791dcee3b | ||
|
|
e532a24327 | ||
|
|
1aba2a5f23 | ||
|
|
aead84bf0f | ||
|
|
7980ad008b | ||
|
|
7c35ad90b9 | ||
|
|
9eccb4722b | ||
|
|
536846eb60 | ||
|
|
cc16ac6ac4 | ||
|
|
fd9aebd622 | ||
|
|
21709812bb | ||
|
|
d426088c3f | ||
|
|
b5c25e5c10 | ||
|
|
525ee7f222 | ||
|
|
065c487a90 | ||
|
|
4df5307eaa | ||
|
|
aa7cabb455 | ||
|
|
9e607a3aa3 | ||
|
|
68818067f2 | ||
|
|
2240d4effe |
+10
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.0-beta.1 - 2026-11-10
|
||||
## 0.16.0-beta.2 - 2026-11-11
|
||||
|
||||
- **Background tasks:** Paseo now shows the background tasks an agent is running, such as shells and monitors, above the composer, with a Stop button for each. Works with Claude Code, Codex and OpenCode v2. ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162) by [@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- **Plugin sidebar views:** A plugin can replace the sidebar's content with its own layout of workspaces, agents, projects and terminals across every connected host. Pick the view per device under Settings → Sidebar; Paseo's sidebar header and footer stay in place. ([#8906](https://github.com/getpaseo/paseo/pull/8906))
|
||||
@@ -30,6 +30,8 @@
|
||||
- Changed the slash command menu to show loading and error states with Retry, and to stop selecting `/exit` by default ([#8905](https://github.com/getpaseo/paseo/pull/8905))
|
||||
- Changed the workspace header and tabs to switch to compact controls when Explorer or a split pane narrows the workspace ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088) by [@zeyugao](https://github.com/zeyugao))
|
||||
- Changed sending a message to a Pi agent whose process has exited to fail immediately with an instruction to reload the agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
|
||||
- Changed Rename workspace in the sidebar on desktop and wide web layouts to edit the title in place instead of opening a dialog ([#9280](https://github.com/getpaseo/paseo/pull/9280))
|
||||
- Changed What's new and the website changelog to show a release's highlights first, with the full list behind Show all changes ([#9281](https://github.com/getpaseo/paseo/pull/9281))
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -37,6 +39,7 @@
|
||||
- Fixed chats with short collapsed turns opening with empty space until you scrolled ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
|
||||
- Fixed Collapse turns hiding an earlier reply inside a later turn's summary line after an idle Claude, Codex or OpenCode agent was resumed ([#9098](https://github.com/getpaseo/paseo/pull/9098))
|
||||
- Fixed the missing space between a prompt and the reply or collapsed-turn summary line below it ([#9009](https://github.com/getpaseo/paseo/pull/9009))
|
||||
- Fixed a long user message showing without its fade and Show more for one frame when a chat opened ([#9302](https://github.com/getpaseo/paseo/pull/9302))
|
||||
- Fixed Markdown tables showing as literal pipes when written directly below a paragraph with no blank line ([#8485](https://github.com/getpaseo/paseo/pull/8485) by [@ragokan](https://github.com/ragokan), [@suk-6](https://github.com/suk-6), [@thsrite](https://github.com/thsrite))
|
||||
- Fixed linked badge images that share a line with text rendering as full-width blocks on web and desktop ([#8530](https://github.com/getpaseo/paseo/pull/8530))
|
||||
- Fixed View diagram opening fullscreen instead of returning to the inline Mermaid diagram after View source ([#8932](https://github.com/getpaseo/paseo/pull/8932) by [@luoph](https://github.com/luoph))
|
||||
@@ -48,6 +51,7 @@
|
||||
- Fixed Add host sending you to Settings from the sidebar and New workspace ([#8512](https://github.com/getpaseo/paseo/pull/8512))
|
||||
- Fixed a failed agent's red dot and Failed grouping staying in the sidebar after opening the agent or choosing Mark as read ([#8831](https://github.com/getpaseo/paseo/pull/8831))
|
||||
- Fixed a workspace's unread indicator staying on after it was cleared while the agent's runtime was closed ([#9087](https://github.com/getpaseo/paseo/pull/9087))
|
||||
- Fixed unarchiving a workspace not restoring the agents that were archived with it ([#9210](https://github.com/getpaseo/paseo/pull/9210))
|
||||
- Fixed the context window popover closing by itself whenever the chat scrolled during a streaming reply ([#8742](https://github.com/getpaseo/paseo/pull/8742) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed the sidebar usage summary updating only when Usage was opened ([#7806](https://github.com/getpaseo/paseo/pull/7806) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed tooltips and menus above their trigger briefly covering it on Android when their content grew after opening ([#9247](https://github.com/getpaseo/paseo/pull/9247))
|
||||
@@ -73,11 +77,13 @@
|
||||
- Fixed Codex "Apply file changes" approvals showing no file name or diff before you accept ([#8585](https://github.com/getpaseo/paseo/pull/8585) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed the timeline showing only the first file's diff when Codex edits several files in one patch ([#8613](https://github.com/getpaseo/paseo/pull/8613) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed Codex session discovery and startup intermittently failing with a SQLite initialization error when several Codex operations start at once ([#9008](https://github.com/getpaseo/paseo/pull/9008) by [@colonelpanic8](https://github.com/colonelpanic8), [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Fixed Codex reconnect attempts and warnings not appearing in the timeline ([#9370](https://github.com/getpaseo/paseo/pull/9370) by [@whatamelon](https://github.com/whatamelon))
|
||||
- Fixed OpenCode conversations that were mid-turn during a daemon restart showing idle and ignoring Stop ([#8698](https://github.com/getpaseo/paseo/pull/8698))
|
||||
- Fixed OpenCode being told you declined a tool call when you sent a message while its approval was pending ([#8477](https://github.com/getpaseo/paseo/pull/8477))
|
||||
- Fixed reopened Pi conversations missing the messages from before a context compaction ([#8523](https://github.com/getpaseo/paseo/pull/8523))
|
||||
- Fixed archived Pi chats failing to open with `spawn pi ENOENT` after their working folder was removed ([#8442](https://github.com/getpaseo/paseo/pull/8442) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed reading an archived Pi chat leaving a Pi process running when idle runtime cleanup is disabled ([#8554](https://github.com/getpaseo/paseo/pull/8554) by [@rylwin](https://github.com/rylwin))
|
||||
- Fixed resumed pi-subagents runs missing from the Subagents track ([#9261](https://github.com/getpaseo/paseo/pull/9261) by [@xxachangxx](https://github.com/xxachangxx))
|
||||
- Fixed a prompt sent to an ACP agent whose process has exited staying in running forever instead of failing with an error ([#8406](https://github.com/getpaseo/paseo/pull/8406) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed an ACP agent staying stuck on "waiting for permission" after it abandoned an approval request and ended its turn ([#8548](https://github.com/getpaseo/paseo/pull/8548) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed changing the mode of an ACP agent failing with `invalid_params` after the agent sends a config update ([#8912](https://github.com/getpaseo/paseo/pull/8912) by [@a-sh](https://github.com/a-sh))
|
||||
@@ -109,10 +115,13 @@
|
||||
### Plugins
|
||||
|
||||
- Added plugin subscriptions, which stream live events from the daemon to a plugin's panels, and binary (`Uint8Array`) payloads for plugin RPCs and events ([#8527](https://github.com/getpaseo/paseo/pull/8527))
|
||||
- Added an agent's background task count (`backgroundWork.count`) and `background` flag to plugin agent snapshots, and a `background` flag to agent hooks ([#9362](https://github.com/getpaseo/paseo/pull/9362))
|
||||
- Changed Settings → Plugins to open a plugin's settings from a cog button, with a tab per settings screen ([#8675](https://github.com/getpaseo/paseo/pull/8675))
|
||||
- Added an upward chevron to plugin composer pills that open a menu or popover ([#8673](https://github.com/getpaseo/paseo/pull/8673))
|
||||
- Changed daemon startup to launch configured plugins concurrently instead of one after another ([#8427](https://github.com/getpaseo/paseo/pull/8427) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed registry plugin installs failing with "npm artifact changed since review" when the host's npm uses a registry mirror ([#8756](https://github.com/getpaseo/paseo/pull/8756) by [@tlipoca9](https://github.com/tlipoca9))
|
||||
- Fixed a plugin the daemon terminated for an invalid message staying listed with no exit reported ([#9285](https://github.com/getpaseo/paseo/pull/9285) by [@Brandon168](https://github.com/Brandon168))
|
||||
- Fixed ACP provider plugins dropping the token usage an agent reports in its prompt response ([#9396](https://github.com/getpaseo/paseo/pull/9396) by [@Myasuka](https://github.com/Myasuka))
|
||||
- Fixed the published `@getpaseo/*` npm packages declaring no license; they now declare Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816) by [@mdlawson](https://github.com/mdlawson), [@ragokan](https://github.com/ragokan))
|
||||
- Fixed forking from an earlier reply failing with "This checkpoint changed after it was created" after a plugin updated one of its timeline cards ([#8746](https://github.com/getpaseo/paseo/pull/8746) by [@monarchmaisuriya](https://github.com/monarchmaisuriya))
|
||||
- Fixed Collapse turns leaving a turn open, or folding to an earlier reply, when a plugin renders the final reply ([#8811](https://github.com/getpaseo/paseo/pull/8811) by [@geoqiao](https://github.com/geoqiao))
|
||||
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.0-beta.1 - 2026-11-10
|
||||
## 0.16.0-beta.2 - 2026-11-11
|
||||
|
||||
- **后台任务**:Paseo 现在会在 Composer 上方显示 Agent 正在运行的后台任务,例如 shell 和监视器,每项都带“停止”按钮。支持 Claude Code、Codex 和 OpenCode v2。 ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162),贡献者:[@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- **插件侧边栏视图**:插件可以用自己的布局替换侧边栏内容,展示所有已连接主机上的工作区、Agent、项目和终端。在“设置 → 侧边栏”中为每台设备选择视图;Paseo 的侧边栏页眉和页脚保持不变。 ([#8906](https://github.com/getpaseo/paseo/pull/8906))
|
||||
@@ -30,6 +30,8 @@
|
||||
- 斜杠命令菜单现在显示加载和错误状态并提供“重试”,且不再默认选中 `/exit` ([#8905](https://github.com/getpaseo/paseo/pull/8905))
|
||||
- 文件浏览器(Explorer)或分屏使工作区变窄时,工作区页眉和标签页改用紧凑控件 ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088),贡献者:[@zeyugao](https://github.com/zeyugao))
|
||||
- 向进程已退出的 Pi Agent 发送消息时立即失败,并提示重新加载该 Agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
|
||||
- 在桌面端和宽屏网页布局中,侧边栏的“重命名 workspace”改为在原处编辑标题,不再打开对话框 ([#9280](https://github.com/getpaseo/paseo/pull/9280))
|
||||
- “更新内容”和网站更新日志改为先显示每个版本的亮点,完整列表收在“显示全部变更”之后 ([#9281](https://github.com/getpaseo/paseo/pull/9281))
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -37,6 +39,7 @@
|
||||
- 修复包含较短折叠轮次的聊天打开时留有空白、需滚动后才填满的问题 ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
|
||||
- 修复空闲的 Claude、Codex 或 OpenCode Agent 恢复后,“折叠轮次”把较早的回复藏进后一轮摘要行的问题 ([#9098](https://github.com/getpaseo/paseo/pull/9098))
|
||||
- 修复提示词与其下方的回复或折叠轮次摘要行之间缺少间距的问题 ([#9009](https://github.com/getpaseo/paseo/pull/9009))
|
||||
- 修复聊天打开时,较长的用户消息有一帧不显示渐隐效果和“展开”的问题 ([#9302](https://github.com/getpaseo/paseo/pull/9302))
|
||||
- 修复紧跟在段落之后、中间没有空行的 Markdown 表格显示为原始竖线的问题 ([#8485](https://github.com/getpaseo/paseo/pull/8485),贡献者:[@ragokan](https://github.com/ragokan)、[@suk-6](https://github.com/suk-6)、[@thsrite](https://github.com/thsrite))
|
||||
- 修复网页版和桌面端中与文字同行的带链接徽章图片被渲染成整行宽度的问题 ([#8530](https://github.com/getpaseo/paseo/pull/8530))
|
||||
- 修复“查看源码”后点击“查看图表”会打开全屏、而不是回到内联 Mermaid 图表的问题 ([#8932](https://github.com/getpaseo/paseo/pull/8932),贡献者:[@luoph](https://github.com/luoph))
|
||||
@@ -48,6 +51,7 @@
|
||||
- 修复从侧边栏和“新建工作区”点击“添加主机”会跳转到“设置”的问题 ([#8512](https://github.com/getpaseo/paseo/pull/8512))
|
||||
- 修复打开失败的 Agent 或选择 Mark as read 后,其红点和“失败”分组仍留在侧边栏的问题 ([#8831](https://github.com/getpaseo/paseo/pull/8831))
|
||||
- 修复在 Agent 运行时已关闭的情况下清除未读后,工作区的未读指示仍然亮着的问题 ([#9087](https://github.com/getpaseo/paseo/pull/9087))
|
||||
- 修复取消归档工作区时未恢复随之归档的 Agent 的问题 ([#9210](https://github.com/getpaseo/paseo/pull/9210))
|
||||
- 修复流式回复期间聊天一滚动上下文窗口浮层就自行关闭的问题 ([#8742](https://github.com/getpaseo/paseo/pull/8742),贡献者:[@3ae3ae](https://github.com/3ae3ae))
|
||||
- 修复侧边栏用量摘要只在打开“使用情况”时才更新的问题 ([#7806](https://github.com/getpaseo/paseo/pull/7806),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 修复 Android 上位于触发元素上方的提示和菜单在打开后内容变多时短暂盖住触发元素的问题 ([#9247](https://github.com/getpaseo/paseo/pull/9247))
|
||||
@@ -73,11 +77,13 @@
|
||||
- 修复 Codex 的“Apply file changes”审批在你接受之前不显示文件名和 diff 的问题 ([#8585](https://github.com/getpaseo/paseo/pull/8585),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 修复 Codex 在一个补丁中编辑多个文件时,时间线只显示第一个文件的 diff 的问题 ([#8613](https://github.com/getpaseo/paseo/pull/8613),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 修复多个 Codex 操作同时启动时,Codex 会话发现和启动偶发 SQLite 初始化错误的问题 ([#9008](https://github.com/getpaseo/paseo/pull/9008),贡献者:[@colonelpanic8](https://github.com/colonelpanic8)、[@michaelmwu](https://github.com/michaelmwu))
|
||||
- 修复 Codex 的重连尝试和警告未显示在时间线中的问题 ([#9370](https://github.com/getpaseo/paseo/pull/9370),贡献者:[@whatamelon](https://github.com/whatamelon))
|
||||
- 修复 daemon 重启时正处于轮次中的 OpenCode 对话显示为空闲并忽略“停止”的问题 ([#8698](https://github.com/getpaseo/paseo/pull/8698))
|
||||
- 修复审批待处理期间你发送消息时,OpenCode 被告知你拒绝了工具调用的问题 ([#8477](https://github.com/getpaseo/paseo/pull/8477))
|
||||
- 修复重新打开的 Pi 对话缺少上下文压缩之前的消息的问题 ([#8523](https://github.com/getpaseo/paseo/pull/8523))
|
||||
- 修复工作文件夹被移除后,已归档的 Pi 聊天因 `spawn pi ENOENT` 无法打开的问题 ([#8442](https://github.com/getpaseo/paseo/pull/8442),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复关闭空闲运行时清理的情况下,读取已归档的 Pi 聊天会遗留一个 Pi 进程的问题 ([#8554](https://github.com/getpaseo/paseo/pull/8554),贡献者:[@rylwin](https://github.com/rylwin))
|
||||
- 修复恢复的 pi-subagents 运行未出现在 Subagent 栏中的问题 ([#9261](https://github.com/getpaseo/paseo/pull/9261),贡献者:[@xxachangxx](https://github.com/xxachangxx))
|
||||
- 修复向进程已退出的 ACP Agent 发送提示词后一直停留在运行中、而不是报错失败的问题 ([#8406](https://github.com/getpaseo/paseo/pull/8406),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复 ACP Agent 放弃审批请求并结束轮次后一直卡在“waiting for permission”的问题 ([#8548](https://github.com/getpaseo/paseo/pull/8548),贡献者:[@3ae3ae](https://github.com/3ae3ae))
|
||||
- 修复 ACP Agent 发送配置更新后,更改其模式因 `invalid_params` 失败的问题 ([#8912](https://github.com/getpaseo/paseo/pull/8912),贡献者:[@a-sh](https://github.com/a-sh))
|
||||
@@ -109,10 +115,13 @@
|
||||
### 插件
|
||||
|
||||
- 新增插件订阅,可将 daemon 的实时事件流式推送到插件面板;插件 RPC 和事件支持二进制(`Uint8Array`)负载 ([#8527](https://github.com/getpaseo/paseo/pull/8527))
|
||||
- 插件的 Agent 快照新增后台任务数量(`backgroundWork.count`)和 `background` 标记,Agent 钩子新增 `background` 标记 ([#9362](https://github.com/getpaseo/paseo/pull/9362))
|
||||
- “设置 → 插件”改为通过齿轮按钮打开插件设置,每个设置页面对应一个标签页 ([#8675](https://github.com/getpaseo/paseo/pull/8675))
|
||||
- 为会打开菜单或浮层的插件 Composer 胶囊按钮新增向上的箭头 ([#8673](https://github.com/getpaseo/paseo/pull/8673))
|
||||
- daemon 启动时改为并发启动已配置的插件,不再逐个启动 ([#8427](https://github.com/getpaseo/paseo/pull/8427),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复主机的 npm 使用镜像源时,从插件库安装插件因“npm artifact changed since review”失败的问题 ([#8756](https://github.com/getpaseo/paseo/pull/8756),贡献者:[@tlipoca9](https://github.com/tlipoca9))
|
||||
- 修复 daemon 因无效消息终止插件后,该插件仍留在列表中且未报告退出的问题 ([#9285](https://github.com/getpaseo/paseo/pull/9285),贡献者:[@Brandon168](https://github.com/Brandon168))
|
||||
- 修复 ACP Provider 插件丢弃 Agent 在提示词响应中报告的 token 用量的问题 ([#9396](https://github.com/getpaseo/paseo/pull/9396),贡献者:[@Myasuka](https://github.com/Myasuka))
|
||||
- 修复已发布的 `@getpaseo/*` npm 包未声明许可证的问题;现在声明为 Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816),贡献者:[@mdlawson](https://github.com/mdlawson)、[@ragokan](https://github.com/ragokan))
|
||||
- 修复插件更新了自己的某张时间线卡片后,从较早的回复分叉因“This checkpoint changed after it was created”失败的问题 ([#8746](https://github.com/getpaseo/paseo/pull/8746),贡献者:[@monarchmaisuriya](https://github.com/monarchmaisuriya))
|
||||
- 修复由插件渲染最终回复时,“折叠轮次”让轮次保持展开或折叠到较早回复的问题 ([#8811](https://github.com/getpaseo/paseo/pull/8811),贡献者:[@geoqiao](https://github.com/geoqiao))
|
||||
|
||||
@@ -112,6 +112,15 @@ need an artifact. `android:clear` removes the generated Android project.
|
||||
|
||||
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
|
||||
|
||||
With an emulator or device connected, run the hardware keyboard's window-dispatch regression tests
|
||||
from `packages/app/android`:
|
||||
|
||||
```bash
|
||||
./gradlew :paseo-hardware-keyboard:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
These check terminal arrow ownership, held-key repeats and modifiers, and ordinary text-field editing.
|
||||
|
||||
### Diagnostic logs without ADB
|
||||
|
||||
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
|
||||
|
||||
+13
-11
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which uses React Native’s press lifecycle on native with a short press delay so scrolling cancels pending feedback. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
@@ -40,9 +40,11 @@ Weight has three tiers, applied by role:
|
||||
|
||||
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
|
||||
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text. The composer's empty placeholder is quieter; it uses `foregroundExtraMuted`.
|
||||
|
||||
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
|
||||
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control, and for the composer's empty placeholder. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
|
||||
|
||||
A theme states places and identity; `packages/app/src/styles/derive-colors.ts` derives their relationships. Sidebar hover and selected fills step from the sidebar toward the foreground. For plugin themes, passive text on those states derives against the actual state surface; page tertiary text is a separate relationship. Explicit plugin semantic overrides take precedence over derived defaults. Built-in themes retain their chosen passive text. `packages/app/src/styles/theme-contrast.test.ts` checks the built-in and representative plugin palettes against contrast floors.
|
||||
|
||||
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
||||
|
||||
@@ -70,7 +72,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
|
||||
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
|
||||
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Touch-sized toolbar glyphs are 20pt at the app's 1.5px stroke. An icon-only control's glyph matches the header icons and rests at `foreground`; a glyph beside a label rests at `foregroundMuted` so the two read as one quiet unit (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
|
||||
|
||||
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`). Touch-sized the disc keeps a 6pt clearance from its neighbour, because it is always painted and a pressed neighbour would merge with it. The attach button sits 2pt closer to the picker than its highlight would put it: the plus is all thin arms, so the full gap reads wide. That offset is set by eye.
|
||||
|
||||
@@ -130,7 +132,7 @@ A section or group explains itself through the `info` prop on `<SettingsSection>
|
||||
|
||||
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. A row that opens something (`<SettingsDetailRow>`, a collapsible row's header) is at least as tall as one holding a default control, so a list where only some of those rows carry a switch keeps one row height. Other rows take their content's height. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
|
||||
|
||||
@@ -203,11 +205,11 @@ Terminology:
|
||||
|
||||
## 11. States
|
||||
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` leads the line it relates to, never trails a label: a settings row's hint takes `hintStatus="busy"` while its work runs, and `hintStatus="pending"` holds the line with a skeleton until its value is read (`packages/app/src/components/settings/row-header.tsx`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
|
||||
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `sm` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
|
||||
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/components/ui/form-field.tsx`). A settings row that is broken says so in its hint with `hintStatus="error"`: a red dot and the error in `statusDanger`.
|
||||
|
||||
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||
|
||||
@@ -229,13 +231,13 @@ Changing state must not move the layout. A row that grows when its badge arrives
|
||||
|
||||
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
|
||||
|
||||
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
|
||||
A trailing chevron opens a detail, as a page or a sheet (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the pressable; use `<SettingsDetailRow>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx`), provider row (`packages/app/src/provider-settings/internal/provider-row.tsx`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
|
||||
|
||||
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
|
||||
|
||||
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
|
||||
|
||||
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles passes its `<Switch>` as the `control` of `<SettingsDetailRow>`, before the chevron; the switch stops the press from reaching the row (`packages/app/src/provider-settings/internal/provider-row.tsx`). A list the user adds to ends with a muted `<SettingsAddRow>`. Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
|
||||
|
||||
@@ -267,7 +269,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`, except the composer's empty placeholder, which uses `foregroundExtraMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
@@ -284,7 +286,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
| Pattern | Reference |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/provider-settings/internal/provider-list.tsx` |
|
||||
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
||||
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
||||
|
||||
@@ -113,6 +113,38 @@ host that is either a known cloud host or one the CLI is already authenticated
|
||||
to. Adapter probes must not make anonymous HTTP requests to remote-derived
|
||||
hosts, and adapters must not route credentials to an unauthenticated host.
|
||||
|
||||
### Web links: `forgeWeb`
|
||||
|
||||
The daemon publishes `forgeWeb` on the workspace descriptor and on
|
||||
`checkout_status_response`. The app builds every forge URL from it instead of
|
||||
parsing `origin` itself.
|
||||
|
||||
- `host` is the browsable host: an `ssh -G` alias such as `github.com-work` and
|
||||
a cloud SSH host such as `ssh.github.com` resolve to the cloud host; self-hosted
|
||||
hosts pass through.
|
||||
- `referenceRepository` is the repository that owns issue and change-request
|
||||
numbers: the parent when `origin` is a fork. `#123` links there; `owner/repo#123`
|
||||
keeps the repository you wrote.
|
||||
- Branch and file links stay on the `origin` repository. A feature branch usually
|
||||
exists only on the fork.
|
||||
|
||||
An adapter opts into `referenceRepository` with the optional
|
||||
`ForgeService.getReferenceRepository`. GitHub reads `parent` from `gh repo view`,
|
||||
GitLab reads `forked_from_project` from `glab api`, and the Gitea family reads
|
||||
`parent` from `tea api`. The lookup always names the `origin` repository
|
||||
explicitly, so it does not depend on `gh`'s default-repository choice, and it
|
||||
rejects on any CLI failure. `packages/server/src/server/forge-web.ts` keeps a
|
||||
successful answer for as long as the remote stays the same. The first snapshot
|
||||
publishes `{ host }` immediately and the reference repository follows in a later
|
||||
update, so a slow or signed-out forge CLI never holds back the checkout's git
|
||||
state or pull request status. A failed lookup (signed out, network down) leaves
|
||||
links on `origin` and is retried by the next refresh or pull request status poll
|
||||
once a minute has passed.
|
||||
|
||||
Daemons older than v0.16.0 omit the field; the app then derives the host from the
|
||||
remote and links `#N` to `origin`. That fallback is tagged `COMPAT(forgeWeb)` in
|
||||
`packages/app/src/git/forge-url.ts`.
|
||||
|
||||
## App
|
||||
|
||||
Each app forge splits into two modules so pure logic never imports the client
|
||||
|
||||
@@ -61,6 +61,10 @@ The daemon journals the assigned agent ID before creation. Concurrent retries sh
|
||||
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
|
||||
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
|
||||
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
|
||||
A direct send (one without `sourceAgentId`) rejected before its prompt is handed to a run, for
|
||||
example because the agent could not be loaded or the requested mode could not be set, leaves no
|
||||
receipt, so retrying its message ID submits it. A failure during or after run startup is treated
|
||||
as an unfinished delivery.
|
||||
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
|
||||
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
|
||||
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
|
||||
|
||||
@@ -20,9 +20,16 @@ settings the native composer does not implement.
|
||||
|
||||
## Native sources
|
||||
|
||||
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
|
||||
that must bypass the focused text control: composer Return and the top overlay's
|
||||
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
|
||||
after the focused text control has already handled the press, so the pipeline's
|
||||
`preventDefault` cannot stop a key from typing. A key the text control would type
|
||||
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
|
||||
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
|
||||
active in the current focus scope, such as Shift+Tab in the composer.
|
||||
`sources/native.ts` computes the claims from the published bindings and recomputes
|
||||
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
|
||||
text controls do not type them. Android takes no claims: a focused `EditText`
|
||||
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
press is still observed by the window.
|
||||
|
||||
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||
@@ -85,6 +92,11 @@ Escape interrupts an agent only in the message input or an unclaimed surface.
|
||||
A focused terminal excludes that binding. The source leaves terminal Escape alone;
|
||||
terminal Escape/Ctrl forwarding is a separate feature.
|
||||
|
||||
Terminal arrows never pass through the shortcut pipeline. The Android hardware
|
||||
module claims them while the terminal's hidden input (`nativeID`) owns the focus.
|
||||
`TextInput.onKeyPress` only sees IME keys, and an arrow that falls through moves
|
||||
the hidden caret and desyncs the next text change, so the pipeline cannot own them.
|
||||
|
||||
## Keys shared with text editing
|
||||
|
||||
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
|
||||
|
||||
+13
-4
@@ -27,9 +27,17 @@ interface MobilePanelSelection {
|
||||
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
||||
Compact panel selection is not persisted; a cold start begins at `agent`.
|
||||
|
||||
Only an overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`. App
|
||||
chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`, `dock`, or
|
||||
`hidden`) to `panel-store`, which drops any target the layout does not present. A stale
|
||||
An overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`.
|
||||
A compact Settings visit entered from the sidebar suspends it: the host stays mounted and
|
||||
laid out on native. Its target and scroll position survive until return to the originating
|
||||
workspace. Route changes and `onLayout` both precede native screen appearance, so the sidebar
|
||||
stays visible until Settings emits the native navigator's `transitionEnd`. Web uses layout.
|
||||
Input and activity stop during the handoff. Widening or leaving for another destination discards
|
||||
the return. Commands issued while suspended settle immediately behind Settings, so explicit
|
||||
agent navigation does not reveal a closing sidebar animation.
|
||||
|
||||
App chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`,
|
||||
`dock`, `hidden`, or `suspended`) to `panel-store`, which drops any target the layout does not present. A stale
|
||||
`agent-list` would reopen the overlay on the next collapse; a stale `file-explorer` after widening
|
||||
would block the navigation swipe, which begins only from `agent`.
|
||||
|
||||
@@ -120,7 +128,8 @@ definition, no longer eligible to begin.
|
||||
the overlay host fills the window.
|
||||
- The overlay's backdrop covers the whole window; safe-area padding applies to the sidebar and
|
||||
workspace content only. It closes by swiping back, tapping the backdrop, or picking a destination:
|
||||
every sidebar destination closes the overlay before it navigates.
|
||||
workspace destinations close the overlay before navigating. Settings suspends the compact
|
||||
sidebar for the duration of its visit instead.
|
||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
|
||||
@@ -510,6 +510,18 @@ before calling status; it exposes your diagnostic through the normal provider di
|
||||
A command-backed provider without status is available when its executable resolves. Providers with
|
||||
neither command nor status retain connection-based availability.
|
||||
|
||||
Implement optional `installation` to fill the **Installation** section of provider settings. The
|
||||
bridge sends each callback as a `provider.installation.*` request carrying the launch `status`
|
||||
receives plus `resolved`. `resolved: false` means the executable was not found and `command` is
|
||||
the configured name, so answer `not_installed` instead of spawning it. Inspect and update-check
|
||||
requests use the 30 s plugin request timeout; the daemon's 25 s update-check deadline sits below
|
||||
it, so a slow check reports that deadline. `update` has no request timeout and streams output
|
||||
through `provider.installation.report`. When the daemon stops waiting on any of the three, it
|
||||
sends `provider.installation.cancel`, which aborts the callback's `signal`.
|
||||
The rules shared with built-ins are in [providers.md](providers.md#installation-and-updates); the
|
||||
author contract is in the
|
||||
[public provider guide](../public-docs/plugins/providers.md#report-installation-and-updates).
|
||||
|
||||
A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label,
|
||||
description, and model configuration through the normal provider registry. `enabled: false` disables
|
||||
selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin,
|
||||
@@ -624,6 +636,21 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute workspace row badge items
|
||||
|
||||
The [public reference](../public-docs/plugins/reference.md#workspace-row-badge-items) owns the author API.
|
||||
The host lives in `packages/app/src/plugins/workspace-row-badge-items/`; `WorkspaceMetaRow` draws a host's
|
||||
items after its own.
|
||||
|
||||
- Plugin items render with no wrapper view. The meta line spaces children with `gap`, so an empty
|
||||
wrapper would leave a gap on rows the plugin has nothing to say about. That is also why
|
||||
the row adds no separator around plugin items; a plugin renders its slot.
|
||||
- A row only renders items from plugins on its own host. The Show menu coalesces items across hosts
|
||||
by `<pluginId>/<itemId>`, the key in the `sidebarPluginRowBadgeItems` setting, and places them in
|
||||
their own group below a separator. A missing key is visible.
|
||||
- Previews (Settings > Host appearance) pass no `workspace` to `WorkspaceMetaRow`, so plugin items
|
||||
stay off them.
|
||||
|
||||
## Contribute file panels
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
|
||||
|
||||
+54
-1
@@ -150,6 +150,15 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
|
||||
OMP rewind is a branch, not an edit: `branch` writes a new session file whose header names the
|
||||
previous file as `parentSession` and leaves that previous file untouched. A rewind chain therefore
|
||||
adds one file to `~/.omp/agent/sessions` per step, and `omp --resume` lists all of them. The
|
||||
previous file also keeps whatever head it had recorded, which can be an older branch than the one
|
||||
the user last saw. Treat the provider session file selected by OMP as the authority; do not assume
|
||||
the newest file or the newest message wins. Import listings hand back a file path, so persist the
|
||||
resumed session's own identity (`sessionId`) with that path as `nativeHandle`, not the listed
|
||||
handle as both.
|
||||
|
||||
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
@@ -160,7 +169,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. V2 discovers definitions from each bound session's catalog: plugin-contributed tools can be absent from the global manifest, and an allowlist cannot register a missing definition. Registration is location-wide, so session filtering and caller-scoped execution must remain in place. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
@@ -254,6 +263,50 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
|
||||
---
|
||||
|
||||
## Installation and updates
|
||||
|
||||
The provider list and the **Installation** section of a provider's page read `AgentClient.installation`. Built-ins set
|
||||
it on the client; plugins set `ProviderRegistration.installation`, which the plugin bridge maps to
|
||||
the same interface. `ProviderInstallations` in `packages/server/src/server/agent/provider-installation/`
|
||||
owns deadlines, caching and the one running update per provider, so a provider only answers.
|
||||
|
||||
Declare only what the vendor offers:
|
||||
|
||||
- `installUrl` and `updateUrl` link the vendor's own install and update docs.
|
||||
- `inspect` reports the version the CLI prints. Versions are opaque: Paseo shows them as written
|
||||
and never parses or compares them.
|
||||
- `checkForUpdates` asks the source the CLI's own updater reads, such as the vendor's release
|
||||
endpoint or the CLI's `update --check`. Leave it out when that source is inside the binary or
|
||||
depends on how the CLI was installed, which is why Copilot and OpenCode have no check. Do not
|
||||
build a release lookup of your own.
|
||||
- `update` runs the CLI's own update command and passes its output through, using
|
||||
`runUpdateCommand`. Paseo adds no verification, signature check or rollback, so the update does
|
||||
exactly what the same command does in a terminal.
|
||||
|
||||
The three calls are separate because they cost differently. `inspect` runs for every provider
|
||||
whenever the provider list shows, because each row states its version; a provider's page shares the
|
||||
row's request. It has a 20 s deadline and a 30 s cache. `checkForUpdates` usually reaches the
|
||||
network, so the app asks only after `inspect` reports the CLI installed, and a conclusive answer
|
||||
holds for an hour while `inspect` reports the same version string. Report every part of the version
|
||||
the check compares, such as Muse's build, or a CLI updated outside Paseo keeps its stale answer. `update` runs for minutes, streams output, and is aborted only when the daemon
|
||||
stops.
|
||||
|
||||
The update check only informs. Once `inspect` reports the CLI installed, the app offers **Update**
|
||||
for a provider that declares `update`, or **Update instructions** for one that declares only
|
||||
`updateUrl`, whatever the check answers and whether the provider has one. The check's answer shows
|
||||
beside the action when it has one. Copilot and OpenCode have no check, so they show **Update** alone.
|
||||
|
||||
Write the success message for the provider's process model. A CLI that Paseo starts once per agent
|
||||
uses `updatedForNewAgents()`. OpenCode agents share one server that keeps the old binary running,
|
||||
so its message asks the user to restart the daemon.
|
||||
|
||||
`createCliInstallation()` covers a CLI that prints `--version` and updates with a subcommand. It
|
||||
resolves the configured command, so a profile that extends a built-in reports and updates the CLI
|
||||
that profile launches. Clients for `extends: "acp"` providers declare no installation.
|
||||
|
||||
Test with fake executables, as `providers/provider-update-checks.test.ts` does. Running a real
|
||||
updater during development replaces the CLI on your machine.
|
||||
|
||||
## Usage sources
|
||||
|
||||
See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the
|
||||
|
||||
+2
-1
@@ -248,6 +248,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
|
||||
- The user-facing beta instructions, including the public TestFlight join link for the `Paseo Beta` group, live in `public-docs/updates.md` and its `zh-CN` pair. Update them when a beta path is added or removed.
|
||||
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
@@ -488,7 +489,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
|
||||
- The website download page defaults to GitHub's latest published **stable** release.
|
||||
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
|
||||
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
|
||||
- Homebrew, the Play Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. The iOS row swaps the App Store pill for the public TestFlight link (`testFlightBetaUrl` in `packages/website/src/downloads.tsx`). When another surface gains a beta path, add its row back in `packages/website/src/routes/{-$locale}/download.tsx`.
|
||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
|
||||
+1
-1
@@ -346,7 +346,7 @@ Gotchas:
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
|
||||
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Settings retains its screen and form; `AppearanceText` refreshes numeric text styles at the leaf, and its font fields use `withUnistyles` to refresh input styles without replacing the inputs. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
|
||||
- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-vG7v6uq2ADtOILEuRq9q3wTDUENAhRBbzI7UGBsWOqo=
|
||||
sha256-e6BNeOJvy1uyDe/XW9hdr7NBgJ+t9tlNum5oLNefREI=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37468,7 +37468,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39637,13 +39637,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/server": "0.16.0-beta.1",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@getpaseo/server": "0.16.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -39890,11 +39890,11 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/relay": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@getpaseo/relay": "0.16.0-beta.2",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -39906,7 +39906,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40149,7 +40149,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40195,7 +40195,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -40225,7 +40225,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
@@ -40233,8 +40233,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40244,8 +40244,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40267,7 +40267,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
@@ -40295,7 +40295,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
@@ -40517,17 +40517,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.16.0-beta.1",
|
||||
"@getpaseo/highlight": "0.16.0-beta.1",
|
||||
"@getpaseo/plugin": "0.16.0-beta.1",
|
||||
"@getpaseo/protocol": "0.16.0-beta.1",
|
||||
"@getpaseo/relay": "0.16.0-beta.1",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/highlight": "0.16.0-beta.2",
|
||||
"@getpaseo/plugin": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@getpaseo/relay": "0.16.0-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41474,7 +41474,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43084,7 +43084,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
expectProviderInstalledInSettings,
|
||||
findAcpCatalogProvider,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openAddProvider,
|
||||
openProviderDetail,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
seedSavedSettingsHosts,
|
||||
@@ -47,7 +48,7 @@ test.describe("ACP provider catalog", () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderArea(page);
|
||||
await openAddProvider(page);
|
||||
|
||||
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
|
||||
@@ -69,7 +70,7 @@ test("adds fx from the catalog with only a config entry", async ({ page }, testI
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderArea(page);
|
||||
await openAddProvider(page);
|
||||
|
||||
await findAcpCatalogProvider(page, "fx");
|
||||
await page.screenshot({ path: testInfo.outputPath("fx-catalog.png") });
|
||||
@@ -104,7 +105,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await openAddProviderArea(page);
|
||||
await openAddProvider(page);
|
||||
await findAcpCatalogProvider(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) });
|
||||
await installAcpCatalogProvider(page, "Hoplite");
|
||||
@@ -127,7 +128,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
testInfo.outputPath(`hoplite-removed-${theme}.json`),
|
||||
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
|
||||
);
|
||||
await openAddProviderArea(page);
|
||||
await openAddProvider(page);
|
||||
await findAcpCatalogProvider(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) });
|
||||
await installAcpCatalogProvider(page, "Hoplite");
|
||||
@@ -140,11 +141,11 @@ for (const theme of ["light", "dark"] as const) {
|
||||
}
|
||||
|
||||
async function removeHoplite(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.getByTestId("provider-actions-hoplite").click();
|
||||
await openProviderDetail(page, "Hoplite");
|
||||
page.once("dialog", (dialog) => {
|
||||
void dialog.accept();
|
||||
});
|
||||
await page.getByTestId("provider-remove-hoplite").click();
|
||||
await page.getByTestId("provider-remove").click();
|
||||
}
|
||||
|
||||
test("unsupported host asks for an update without writing Hoplite config", async ({
|
||||
@@ -164,7 +165,7 @@ test("unsupported host asks for an update without writing Hoplite config", async
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, outdatedDaemon.serverId);
|
||||
await openSettingsHostSection(page, outdatedDaemon.serverId, "providers");
|
||||
await openAddProviderArea(page);
|
||||
await openAddProvider(page);
|
||||
await page.getByRole("button", { name: "Add Hoplite", exact: true }).click();
|
||||
const errorToast = page.getByTestId("app-toast-message");
|
||||
await expect(errorToast).toHaveText("Update the host to add Hoplite.");
|
||||
|
||||
@@ -37,7 +37,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 213)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("light-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
@@ -64,7 +64,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(17, 17, 17)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(29, 29, 29)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("pure-black-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
|
||||
@@ -644,6 +644,22 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Crossing into part of the block keeps the selected code lines as a fenced block.
|
||||
await selectAssistantAcrossCodeLines(page, "not a generated link.", "return");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```',
|
||||
);
|
||||
|
||||
// So does starting partway through the block and leaving it.
|
||||
await selectAssistantAcrossCodeLines(page, "answer", "After code.");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Selecting a whole line runs off its end into the newline node. A trailing
|
||||
// newline auto-executes the line when it is pasted into a terminal.
|
||||
await selectAssistantAcrossCodeLines(page, "const", " ");
|
||||
@@ -729,13 +745,25 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
|
||||
test("a mouse drag selects across assistant list, quote, and code blocks", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-drag-",
|
||||
title: "Assistant selection drag",
|
||||
initialPrompt: "Render three paragraphs.",
|
||||
initialPrompt: "Render structured text.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
|
||||
mockAssistantResponse: [
|
||||
"First paragraph.",
|
||||
"",
|
||||
"- List item.",
|
||||
"",
|
||||
"> Quote text.",
|
||||
"",
|
||||
"```ts",
|
||||
"const answer = 1;",
|
||||
"```",
|
||||
"",
|
||||
"Third paragraph.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -760,6 +788,9 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
|
||||
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||
expect(selected).toContain("First paragraph.");
|
||||
expect(selected).toContain("List item.");
|
||||
expect(selected).toContain("Quote text.");
|
||||
expect(selected).toContain("const answer = 1;");
|
||||
expect(selected).toContain("Third paragraph.");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -2297,13 +2297,9 @@ async function changeCodeTypographyFromSettings(
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
|
||||
await page.getByRole("button", { name: "Appearance" }).click();
|
||||
// A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts
|
||||
// from the stored size. Edit the size only in the remounted form, or the remount drops it.
|
||||
const sizeField = page.getByLabel("Code font size");
|
||||
await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", ""));
|
||||
await page.getByLabel("Code font family").fill(typography.fontFamily);
|
||||
await page.getByLabel("Code font family").press("Enter");
|
||||
await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
|
||||
await sizeField.fill(String(typography.fontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedSubagent } from "../support/helpers/subagents";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { expectWorkspaceHeaderVisible, submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||
import {
|
||||
@@ -918,11 +918,12 @@ test.describe("touch composer tracks", () => {
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
await page.setViewportSize({ width: 200, height: 844 });
|
||||
const narrowWidths = await Promise.all(
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
for (const [index, width] of wideWidths.entries())
|
||||
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
|
||||
// The compact layout mounts a new composer, so measure only after it replaces the wide one.
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
for (const [index, pill] of [tasks, subagents, changes].entries())
|
||||
await expect
|
||||
.poll(async () => (await pill.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(wideWidths[index] - 1);
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
|
||||
await track.evaluate((node) => node.clientWidth),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fillComposerDraft,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
function userMessages(page: Page) {
|
||||
@@ -29,7 +30,9 @@ async function pressInComposer(page: Page, key: string) {
|
||||
}
|
||||
|
||||
test.describe("Composer recall", () => {
|
||||
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
|
||||
test("recalls, edits, and sends messages while keeping slash autocomplete usable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-",
|
||||
title: "Composer recall",
|
||||
@@ -37,49 +40,44 @@ test.describe("Composer recall", () => {
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
await test.step("walk sent history, edit a recalled draft, and send it", async () => {
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
});
|
||||
await test.step("recall a slash message without opening its picker, then edit it", async () => {
|
||||
await expectComposerDraft(page, "");
|
||||
const reply = await session.client.waitForFinish(session.agentId, 15_000);
|
||||
expect(reply.status).toBe("idle");
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 4);
|
||||
|
||||
test("a recalled slash message does not open the command picker", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-slash-",
|
||||
title: "Composer recall slash",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 1);
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
composerControl,
|
||||
expectGlyphMatchesLabel,
|
||||
expectIconOnlyGlyph,
|
||||
readComposerControlTone,
|
||||
readComposerPlaceholderTone,
|
||||
readComposerTextTone,
|
||||
} from "../support/helpers/composer-toolbar-tone";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const THINKING = "agent-thinking-selector";
|
||||
const MODE = "mode-control";
|
||||
const ATTACH = "message-input-attach-button";
|
||||
const COMBINED_MODEL = "combined-model-selector";
|
||||
|
||||
async function openSeededComposer(
|
||||
page: import("@playwright/test").Page,
|
||||
repoPrefix: string,
|
||||
readyTestId = MODE,
|
||||
) {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
title: "Toolbar tone",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await expect(composerControl(page, readyTestId)).toBeVisible({ timeout: 30_000 });
|
||||
return agent;
|
||||
} catch (error) {
|
||||
await agent.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Composer toolbar glyph tone", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test.describe("on a touch screen", () => {
|
||||
test.use({ hasTouch: true, viewport: { width: 1024, height: 1366 } });
|
||||
|
||||
test("a glyph beside its label matches the label while an icon-only glyph stays foreground", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-touch-");
|
||||
try {
|
||||
const thinking = await readComposerControlTone(composerControl(page, THINKING));
|
||||
const mode = await readComposerControlTone(composerControl(page, MODE));
|
||||
const attach = await readComposerControlTone(composerControl(page, ATTACH));
|
||||
|
||||
expectGlyphMatchesLabel(thinking, "thinking");
|
||||
expectGlyphMatchesLabel(mode, "mode");
|
||||
expectIconOnlyGlyph(attach, "attach");
|
||||
expect(attach.glyph, "an icon-only glyph is brighter than a labelled glyph").not.toBe(
|
||||
thinking.label,
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("on a compact non-touch window", () => {
|
||||
test.use({ hasTouch: false, viewport: { width: 590, height: 844 } });
|
||||
|
||||
test("the icon-only thinking glyph stays foreground and the labelled mode glyph matches its label", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-compact-");
|
||||
try {
|
||||
const thinking = await readComposerControlTone(composerControl(page, THINKING));
|
||||
const mode = await readComposerControlTone(composerControl(page, MODE));
|
||||
const attach = await readComposerControlTone(composerControl(page, ATTACH));
|
||||
|
||||
expectIconOnlyGlyph(thinking, "thinking");
|
||||
expectGlyphMatchesLabel(mode, "mode");
|
||||
expectIconOnlyGlyph(attach, "attach");
|
||||
expect(thinking.glyph, "the icon-only glyphs share one tone").toBe(attach.glyph);
|
||||
expect(thinking.glyph, "an icon-only glyph is brighter than a labelled glyph").not.toBe(
|
||||
mode.label,
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("on a folded compact non-touch window", () => {
|
||||
test.use({ hasTouch: false, viewport: { width: 480, height: 844 } });
|
||||
|
||||
test("the empty composer placeholder is quieter than its labels", async ({ page }) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-placeholder-", COMBINED_MODEL);
|
||||
try {
|
||||
const placeholder = await readComposerPlaceholderTone(composerLocator(page));
|
||||
const extraMuted = await readComposerTextTone(
|
||||
composerControl(page, "combined-model-selector-thinking-label"),
|
||||
);
|
||||
const muted = await readComposerTextTone(
|
||||
composerControl(page, "combined-model-selector-model-label"),
|
||||
);
|
||||
|
||||
expect(placeholder, "the composer paints a placeholder").not.toBeNull();
|
||||
expect(extraMuted, "the folded thinking label paints the extra-muted tone").not.toBeNull();
|
||||
expect(placeholder, "the placeholder uses the extra-muted tone").toBe(extraMuted);
|
||||
expect(placeholder, "the placeholder is quieter than a labelled glyph").not.toBe(muted);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("with a wide pointer", () => {
|
||||
test.use({ hasTouch: false, viewport: { width: 1280, height: 800 } });
|
||||
|
||||
test("a labelled glyph stays muted and matches its label", async ({ page }) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-pointer-");
|
||||
try {
|
||||
const thinking = await readComposerControlTone(composerControl(page, THINKING));
|
||||
const mode = await readComposerControlTone(composerControl(page, MODE));
|
||||
|
||||
expectGlyphMatchesLabel(thinking, "thinking");
|
||||
expectGlyphMatchesLabel(mode, "mode");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -127,8 +127,42 @@ for (const scenario of [
|
||||
await creation.submitPrompt("Retry this workspace and agent together.");
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test(`an edited prompt after failed agent initialization starts the agent in its workspace${scenario.suffix}`, async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Start the original task.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
await startup.fail();
|
||||
await creation.expectStartupFailure();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await scenario.prepare(creation);
|
||||
await creation.submitPrompt("Start the edited task.");
|
||||
await creation.expectPromptVisible("Start the edited task.");
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("an edited agent draft starts after its first start failed", async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
await startup.fail();
|
||||
await creation.submitPrompt("Start the original task.");
|
||||
await creation.expectStartupFailure();
|
||||
await creation.submitPrompt("Start the edited task.");
|
||||
await creation.expectPromptVisible("Start the edited task.");
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const entry of [
|
||||
{
|
||||
name: "agent draft",
|
||||
@@ -159,7 +193,9 @@ for (const entry of [
|
||||
await entry.settled(creation);
|
||||
await creation.expectCreatedAgentError();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("created-agent-prompt-error.png"),
|
||||
});
|
||||
await creation.reloadAgent();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
downloadFileFromPreview,
|
||||
expandFolder,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
@@ -141,6 +142,29 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("downloads an assistant-linked binary outside the workspace", async ({ page }, testInfo) => {
|
||||
const filePath = testInfo.outputPath("outside.bin");
|
||||
const source = Buffer.from([0, 1, 128, 255]);
|
||||
await writeFile(filePath, source);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "outside-binary-",
|
||||
title: "Outside binary",
|
||||
initialPrompt: "Show the file",
|
||||
featureValues: { mockAssistantResponse: `[Open binary](${filePath})` },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await openAssistantMarkdownLink(page, "Open binary");
|
||||
const downloaded = await downloadFileFromPreview(page);
|
||||
expect(downloaded.suggestedFilename()).toBe("outside.bin");
|
||||
const savedFile = testInfo.outputPath("downloaded.bin");
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "screenshot-markdown-link-",
|
||||
|
||||
@@ -2,7 +2,12 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import {
|
||||
downloadFileFromExplorer,
|
||||
downloadFileFromPreview,
|
||||
openFileFromExplorer,
|
||||
openFileExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
@@ -473,3 +478,21 @@ test("downloads a file over direct TCP with its original filename and bytes", as
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
test("downloads an unsupported binary from its preview with original filename and bytes", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fileName = "rapport données.bin";
|
||||
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
|
||||
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, fileName);
|
||||
|
||||
const downloaded = await downloadFileFromPreview(page);
|
||||
expect(downloaded.suggestedFilename()).toBe(fileName);
|
||||
const savedFile = testInfo.outputPath(fileName);
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
addProjectFromSetup,
|
||||
continueSetup,
|
||||
expectSetupStep,
|
||||
expectSheetCoversSetupCard,
|
||||
finishSetup,
|
||||
goToSetupStep,
|
||||
hostGivesAgentsPaseoTools,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
recordFirstLaunch,
|
||||
setupModal,
|
||||
setupPrimaryButton,
|
||||
setupSheet,
|
||||
showSetupAgainFromSettings,
|
||||
skipSetup,
|
||||
startSetupHost,
|
||||
@@ -35,7 +37,11 @@ import {
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { enableRelayAndExpectOffer } from "../support/helpers/pair-device";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
installAcpCatalogProvider,
|
||||
openAddProvider,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
// Midnight's muted foreground (#9a9db0), which the step description is drawn in.
|
||||
@@ -59,11 +65,28 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
|
||||
await test.step("Providers shows the same provider list as Settings", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Your agents");
|
||||
await expectSetupStep(page, "Your providers");
|
||||
await expect(setupModal(page).getByTestId("host-page-providers-card")).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("2-providers.png") });
|
||||
});
|
||||
|
||||
await test.step("a provider's detail and Add provider cover the setup card and lead back to it", async () => {
|
||||
await setupModal(page)
|
||||
.getByRole("button", { name: "Claude provider details", exact: true })
|
||||
.click();
|
||||
const detail = setupSheet(page, "provider-settings-sheet");
|
||||
await expectSheetCoversSetupCard(page, detail);
|
||||
await detail.getByRole("button", { name: "Back to providers", exact: true }).click();
|
||||
await expect(detail).toHaveCount(0);
|
||||
|
||||
await setupModal(page).getByTestId("provider-list-add").click();
|
||||
const add = setupSheet(page, "add-provider-sheet");
|
||||
await expectSheetCoversSetupCard(page, add);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(add).toHaveCount(0);
|
||||
await expectSetupStep(page, "Your providers");
|
||||
});
|
||||
|
||||
await test.step("with no agent history, Add project opens the regular flow and the folder shows as added", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
@@ -103,6 +126,33 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a provider removed in setup can be added back and removed again", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const host = await startSetupHost("re-add");
|
||||
const detail = setupSheet(page, "provider-settings-sheet");
|
||||
const remove = detail.getByTestId("provider-remove");
|
||||
page.on("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Your providers");
|
||||
|
||||
await openAddProvider(page);
|
||||
await installAcpCatalogProvider(page, "Junie");
|
||||
await remove.click();
|
||||
await expect(detail).toHaveCount(0);
|
||||
|
||||
// Adding it back opens the same provider's sheet, which must not still be removing it.
|
||||
await openAddProvider(page);
|
||||
await installAcpCatalogProvider(page, "Junie");
|
||||
await expect(remove).toHaveText("Remove");
|
||||
await expect(remove).toBeEnabled();
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new user goes from the loading screen straight into setup", async ({ page }) => {
|
||||
const host = await startSetupHost("first-launch");
|
||||
|
||||
@@ -145,7 +195,7 @@ test("changing the theme keeps the setup window in place on every step", async (
|
||||
|
||||
for (const title of [
|
||||
"Welcome to Paseo",
|
||||
"Your agents",
|
||||
"Your providers",
|
||||
"Add your projects",
|
||||
"Let agents use Paseo",
|
||||
"Take your agents with you",
|
||||
@@ -418,7 +468,7 @@ test("in the compact layout, Finish on Orchestration applies its switches", asyn
|
||||
await openAppAsNewUser(page, host);
|
||||
await step("Welcome to Paseo");
|
||||
await primary("Continue").click();
|
||||
await step("Your agents");
|
||||
await step("Your providers");
|
||||
await primary("Continue").click();
|
||||
await step("Add your projects");
|
||||
await primary("Continue").click();
|
||||
|
||||
@@ -5,6 +5,10 @@ import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { contrastRatio } from "../../src/utils/color";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
const PLUGIN_ID = "plugin-theme-e2e";
|
||||
|
||||
@@ -40,6 +44,16 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||
ring: "#9ca0b0",
|
||||
},
|
||||
});
|
||||
for (const [id, name, overrides] of [
|
||||
["solarized-dark", "Solarized Dark", {}],
|
||||
["solarized-dark-custom", "Solarized Dark Custom", { sidebarSelected: "#334455", foregroundExtraMuted: "#ddeeff" }],
|
||||
]) {
|
||||
plugin.addTheme({ id, name, appearance: "dark", colors: {
|
||||
background: "#002b36", foreground: "#839496", raised: "#073642",
|
||||
control: "#073642", border: "#073642", accent: "#268bd2",
|
||||
mutedForeground: "#586e75", ring: "#93a1a1", ...overrides,
|
||||
}});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
@@ -47,6 +61,88 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||
// contributed palette reached the semantic tokens rather than just the swatch.
|
||||
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
|
||||
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
|
||||
const LATTE_RING = "rgb(156, 160, 176)";
|
||||
|
||||
async function selectTheme(page: Page, name: string) {
|
||||
await page.goto("/settings");
|
||||
await openSettingsSection(page, "appearance");
|
||||
const theme = page.getByRole("radio", { name, exact: true });
|
||||
await expect(theme).toBeVisible({ timeout: 30_000 });
|
||||
await theme.click();
|
||||
await expect(theme).toBeChecked();
|
||||
}
|
||||
|
||||
async function expectTimestampContrast(row: Locator) {
|
||||
const timestamp = row.getByTestId("sidebar-workspace-timestamp");
|
||||
await expect(timestamp).toBeVisible();
|
||||
const hex = (rgb: string) =>
|
||||
`#${rgb
|
||||
.match(/\d+/g)!
|
||||
.slice(0, 3)
|
||||
.map((value) => Number(value).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
const background = await row.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
const foreground = await timestamp.evaluate((element) => getComputedStyle(element).color);
|
||||
expect(contrastRatio(hex(background), hex(foreground))).toBeGreaterThanOrEqual(2.5);
|
||||
}
|
||||
|
||||
test("keeps plugin workspace timestamps readable on state surfaces and honors overrides", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-state-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
const selected = await seedMockAgentWorkspace({
|
||||
repoPrefix: "theme-selected-",
|
||||
title: "Selected theme workspace",
|
||||
});
|
||||
const hovered = await seedMockAgentWorkspace({
|
||||
repoPrefix: "theme-hovered-",
|
||||
title: "Hovered theme workspace",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await selectTheme(page, "Solarized Dark");
|
||||
await openAgentRoute(page, selected);
|
||||
const selectedRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${selected.workspaceId}`,
|
||||
);
|
||||
const hoveredRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${hovered.workspaceId}`,
|
||||
);
|
||||
await expect(selectedRow).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(selectedRow).toHaveCSS("background-color", "rgb(33, 58, 66)");
|
||||
await expectTimestampContrast(selectedRow);
|
||||
await hoveredRow.hover();
|
||||
await expect(hoveredRow).toHaveCSS("background-color", "rgb(19, 50, 60)");
|
||||
await expectTimestampContrast(hoveredRow);
|
||||
|
||||
await selectTheme(page, "Solarized Dark Custom");
|
||||
await openAgentRoute(page, selected);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(selectedRow).toHaveCSS("background-color", "rgb(51, 68, 85)");
|
||||
await expect(selectedRow.getByTestId("sidebar-workspace-timestamp")).toHaveCSS(
|
||||
"color",
|
||||
"rgb(221, 238, 255)",
|
||||
);
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close();
|
||||
await selected.cleanup();
|
||||
await hovered.cleanup();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("applies a contributed theme and falls back when its plugin is gone", async ({
|
||||
page,
|
||||
@@ -83,6 +179,13 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
|
||||
});
|
||||
|
||||
await test.step("keyboard focus draws the outline in the theme's ring color", async () => {
|
||||
await page.getByRole("radio", { name: "Catppuccin Latte", exact: true }).click();
|
||||
await page.mouse.move(0, 0);
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator(":focus-visible").first()).toHaveCSS("outline-color", LATTE_RING);
|
||||
});
|
||||
|
||||
await mochaItem.click();
|
||||
await expect(mochaItem).toBeChecked();
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
closeSidebarDisplayPreferences,
|
||||
openMobileAgentSidebar,
|
||||
openSidebarDisplayPage,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "row-badges-e2e";
|
||||
const TITLE = "Badge host workspace";
|
||||
|
||||
const PLUGIN_SOURCE = `import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function Review() {
|
||||
return <WorkspaceRowBadge icon="Eye" label="In review" tone="warning" />;
|
||||
}
|
||||
|
||||
function Docs() {
|
||||
return <WorkspaceRowBadge icon="BookOpen" label="Docs" />;
|
||||
}
|
||||
|
||||
function IconOnly() {
|
||||
return <WorkspaceRowBadge icon="Star" />;
|
||||
}
|
||||
|
||||
function EmptyLabel() {
|
||||
return <WorkspaceRowBadge icon="Circle" label="" />;
|
||||
}
|
||||
|
||||
function Quiet() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function Broken() {
|
||||
throw new Error("badge failed");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addWorkspaceRowBadgeItem({ id: "review", title: "Review state", icon: "Eye", Component: Review });
|
||||
client.addWorkspaceRowBadgeItem({ id: "docs", title: "Docs state", icon: "BookOpen", Component: Docs });
|
||||
client.addWorkspaceRowBadgeItem({ id: "icon", title: "Icon state", icon: "Star", Component: IconOnly });
|
||||
client.addWorkspaceRowBadgeItem({ id: "empty", title: "Empty label state", icon: "Circle", Component: EmptyLabel });
|
||||
client.addWorkspaceRowBadgeItem({ id: "quiet", title: "Quiet state", icon: "Moon", Component: Quiet });
|
||||
client.addWorkspaceRowBadgeItem({ id: "broken", title: "Broken state", icon: "Bug", Component: Broken });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
// The meta line the badges draw on. Each item sits in one wrapper of its own, so the line is two up.
|
||||
const LABELS = { review: "In review", docs: "Docs" } as const;
|
||||
|
||||
function badgeLine(page: Page, itemId: keyof typeof LABELS) {
|
||||
return badge(page, itemId).locator("xpath=../..");
|
||||
}
|
||||
|
||||
function badge(page: Page, itemId: keyof typeof LABELS) {
|
||||
return page.getByLabel(LABELS[itemId], { exact: true }).locator("visible=true");
|
||||
}
|
||||
|
||||
async function toggleShowItem(page: Page, label: string, touch: boolean) {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: label, exact: true }).click();
|
||||
if (touch) {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
} else {
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
}
|
||||
}
|
||||
|
||||
test("a plugin badge shows on workspace rows, hides quietly, and follows its Show toggle", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "row-badges-", title: TITLE });
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-row-badges-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the badge draws on the row; a null item and a throwing item draw nothing", async () => {
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await expect(badge(page, "review")).toContainText("In review");
|
||||
// The null and throwing items after them draw nothing, and the line adds nothing of its own.
|
||||
await expect(badgeLine(page, "review")).toHaveText("In reviewDocs");
|
||||
// An icon-only badge is named by its item's title.
|
||||
await expect(
|
||||
page.getByLabel("Icon state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel("Empty label state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(TITLE).first()).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("wide.png") });
|
||||
});
|
||||
|
||||
await test.step("every item has a Show toggle, and switching one off removes only that badge", async () => {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
for (const label of ["Review state", "Quiet state", "Broken state", "Docs state"]) {
|
||||
await expect(page.getByRole("menuitem", { name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByTestId("sidebar-plugin-row-badge-items-separator")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("show-menu.png") });
|
||||
await page.getByRole("menuitem", { name: "Review state", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
// The first badge is gone, so the next one leads the line without a "·".
|
||||
await expect(badgeLine(page, "docs")).toHaveText("Docs");
|
||||
await toggleShowItem(page, "Review state", false);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the badge draws on a compact layout", async () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("compact.png") });
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,21 +1,22 @@
|
||||
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
findAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openAddProvider,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
async function openProviderCatalog(page: Page) {
|
||||
async function openProviderCatalog(page: Page, viewport?: { width: number; height: number }) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderArea(page);
|
||||
if (viewport) await page.setViewportSize(viewport);
|
||||
await openAddProvider(page);
|
||||
}
|
||||
|
||||
async function expectProviderArtwork(
|
||||
@@ -25,11 +26,11 @@ async function expectProviderArtwork(
|
||||
name: string,
|
||||
) {
|
||||
await findAcpCatalogProvider(page, name);
|
||||
// Catalog tiles have no semantic role; scope artwork to the named provider's tile.
|
||||
const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first();
|
||||
// Catalog rows have no semantic role of their own; scope artwork to the named provider's row.
|
||||
const icon = page.getByTestId(`provider-catalog-row-${id}`).locator("svg").first();
|
||||
await expect(icon).toBeVisible();
|
||||
// Provider artwork is 24px; the generic PackagePlus fallback is 20px.
|
||||
await expect.soft(icon).toHaveAttribute("width", "24");
|
||||
// Artwork draws at the provider list's icon size, so the catalog and the list share a rail.
|
||||
await expect.soft(icon).toHaveAttribute("width", "16");
|
||||
const expectedPaths = await page.evaluate((xml) => {
|
||||
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
|
||||
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
|
||||
@@ -49,3 +50,20 @@ test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async
|
||||
await expectProviderArtwork(page, testInfo, "hermes", "Hermes");
|
||||
await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI");
|
||||
});
|
||||
|
||||
/** The space between a row's icon and its label, as drawn. */
|
||||
async function gapAfterIcon(row: Locator, label: string): Promise<number> {
|
||||
const icon = await row.locator("svg").first().boundingBox();
|
||||
const text = await row.getByText(label, { exact: true }).boundingBox();
|
||||
if (!icon || !text) throw new Error(`${label} has no icon or label on screen`);
|
||||
return text.x - (icon.x + icon.width);
|
||||
}
|
||||
|
||||
test("catalog rows keep the provider list's icon rail on a phone", async ({ page }) => {
|
||||
await openProviderCatalog(page, { width: 390, height: 844 });
|
||||
await findAcpCatalogProvider(page, "Kiro CLI");
|
||||
|
||||
const listGap = await gapAfterIcon(page.getByTestId("provider-row-claude"), "Claude");
|
||||
const catalogGap = await gapAfterIcon(page.getByTestId("provider-catalog-row-kiro"), "Kiro CLI");
|
||||
expect(catalogGap).toBe(listGap);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openCustomModels, removeCustomModel } from "../support/helpers/custom-models";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
|
||||
// A pressed control shows its own progress while the daemon answers, the way Button's `loading`
|
||||
// does: a spinner in place of its icon, no further presses and, on a labelled button, a
|
||||
// present-participle label.
|
||||
|
||||
const MODEL_ID = "in-flight-model";
|
||||
|
||||
test.describe("provider page work in flight", () => {
|
||||
test("says Adding... on the Add button while a custom model is saved", async ({ page }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openCustomModels(page);
|
||||
|
||||
await page.getByRole("button", { name: "Add model", exact: true }).click();
|
||||
const sheet = page.getByTestId("add-custom-model-sheet");
|
||||
await page.getByPlaceholder("e.g. openai/gpt-5").fill(MODEL_ID);
|
||||
gate.holdNextServerMessage("set_daemon_config_response");
|
||||
await sheet.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await gate.waitForHeldServerMessage("set_daemon_config_response");
|
||||
|
||||
const adding = sheet.getByRole("button", { name: "Adding...", exact: true });
|
||||
await expect(adding.getByRole("progressbar")).toBeVisible();
|
||||
await expect(adding).toBeDisabled();
|
||||
|
||||
gate.releaseHeldServerMessage("set_daemon_config_response");
|
||||
await expect(sheet).toBeHidden({ timeout: 30_000 });
|
||||
await removeCustomModel(page, MODEL_ID);
|
||||
});
|
||||
|
||||
test("shows the diagnostic report running again on its re-run button", async ({ page }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openCustomModels(page);
|
||||
|
||||
await page.getByRole("button", { name: "Diagnostic report", exact: true }).click();
|
||||
const sheet = page.getByTestId("provider-diagnostic-sheet");
|
||||
const rerun = sheet.getByRole("button", { name: "Refresh diagnostic", exact: true });
|
||||
await expect(rerun).toBeEnabled({ timeout: 30_000 });
|
||||
// Copy has a report to copy once the first run is in.
|
||||
await expect(sheet.getByRole("button", { name: "Copy diagnostic" })).toBeEnabled();
|
||||
|
||||
gate.holdNextServerMessage("provider_diagnostic_response");
|
||||
await rerun.click();
|
||||
await gate.waitForHeldServerMessage("provider_diagnostic_response");
|
||||
|
||||
const running = sheet.getByRole("button", { name: "Refreshing diagnostic", exact: true });
|
||||
await expect(running.getByRole("progressbar")).toBeVisible();
|
||||
await expect(running).toBeDisabled();
|
||||
|
||||
gate.releaseHeldServerMessage("provider_diagnostic_response");
|
||||
await expect(rerun).toBeEnabled({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,15 @@ import type { Dialog } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
expectProviderNotOnHost,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
leaveProviderDetail,
|
||||
openAddProvider,
|
||||
openProviderDetail,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
@@ -62,13 +66,39 @@ async function clickRemoveProviderAndAcceptWarning(page: Page): Promise<Dialog>
|
||||
expect(dialog.message()).toContain("This deletes the provider entry from config.json.");
|
||||
void dialog.accept();
|
||||
});
|
||||
await page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`).click();
|
||||
await page.getByTestId("provider-remove").click();
|
||||
if (!warning) {
|
||||
throw new Error("Expected a provider removal confirmation dialog, but none was shown.");
|
||||
}
|
||||
return warning;
|
||||
}
|
||||
|
||||
const MISSING_NOTICE = '[data-testid="provider-detail-missing"]';
|
||||
|
||||
/** Records whether the page ever says its provider isn't on the host, however briefly. */
|
||||
async function watchForMissingNotice(page: Page): Promise<void> {
|
||||
await page.evaluate((selector) => {
|
||||
const flags = window as unknown as { sawMissingNotice?: boolean };
|
||||
flags.sawMissingNotice = false;
|
||||
new MutationObserver(() => {
|
||||
if (document.querySelector(selector)) flags.sawMissingNotice = true;
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
}, MISSING_NOTICE);
|
||||
}
|
||||
|
||||
async function expectMissingNoticeNeverShown(page: Page): Promise<void> {
|
||||
const saw = await page.evaluate(
|
||||
() => (window as unknown as { sawMissingNotice?: boolean }).sawMissingNotice,
|
||||
);
|
||||
expect(saw).toBe(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The daemon publishes the provider list without the removed provider before it answers the config
|
||||
* patch. Holding the answer keeps the page in that window.
|
||||
*/
|
||||
const CONFIG_RESPONSE = "set_daemon_config_response";
|
||||
|
||||
test.describe("provider removal", () => {
|
||||
test("removes a custom provider from Settings", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
@@ -79,20 +109,39 @@ test.describe("provider removal", () => {
|
||||
try {
|
||||
await removeCustomProvider(client);
|
||||
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
|
||||
await expect(page.getByTestId("provider-actions-claude")).toHaveCount(0);
|
||||
await openAddProviderArea(page);
|
||||
// A built-in provider has no Danger zone.
|
||||
await openProviderDetail(page, "Claude");
|
||||
await expect(page.getByTestId("provider-remove")).toHaveCount(0);
|
||||
await leaveProviderDetail(page);
|
||||
|
||||
await openAddProvider(page);
|
||||
await installAcpCatalogProvider(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderSource(client, "custom");
|
||||
|
||||
await page.getByTestId(`provider-actions-${CUSTOM_PROVIDER.id}`).click();
|
||||
await expect(page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`)).toBeVisible();
|
||||
await openProviderDetail(page, CUSTOM_PROVIDER.name);
|
||||
await expect(page.getByTestId("provider-remove")).toBeVisible();
|
||||
await watchForMissingNotice(page);
|
||||
gate.holdNextServerMessage(CONFIG_RESPONSE);
|
||||
await clickRemoveProviderAndAcceptWarning(page);
|
||||
await gate.waitForHeldServerMessage(CONFIG_RESPONSE);
|
||||
await expectProviderSource(client, undefined);
|
||||
// Until the removal is answered, the page keeps the provider and the pressed button says so.
|
||||
const removeButton = page.getByTestId("provider-remove");
|
||||
await expect(removeButton).toHaveText("Removing...");
|
||||
await expect(removeButton.getByRole("progressbar")).toBeVisible();
|
||||
await expect(removeButton).toBeDisabled();
|
||||
await expect(page.getByTestId("provider-detail-title")).toHaveText(CUSTOM_PROVIDER.name);
|
||||
gate.releaseHeldServerMessage(CONFIG_RESPONSE);
|
||||
// Removing leaves the provider's page for the list, without first calling it missing.
|
||||
await expect(page.getByTestId("provider-list")).toBeVisible();
|
||||
await expectMissingNoticeNeverShown(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
@@ -100,7 +149,33 @@ test.describe("provider removal", () => {
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await expectProviderSource(client, undefined);
|
||||
} finally {
|
||||
await removeCustomProvider(client).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("says a provider removed elsewhere while its page is open isn't on this host", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
|
||||
clientIdPrefix: "provider-removal-e2e",
|
||||
});
|
||||
|
||||
try {
|
||||
await removeCustomProvider(client);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProvider(page);
|
||||
await installAcpCatalogProvider(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, CUSTOM_PROVIDER.name);
|
||||
await openProviderDetail(page, CUSTOM_PROVIDER.name);
|
||||
|
||||
await removeCustomProvider(client);
|
||||
await expectProviderNotOnHost(page);
|
||||
} finally {
|
||||
await removeCustomProvider(client).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
@@ -130,7 +205,9 @@ test.describe("provider removal", () => {
|
||||
exact: true,
|
||||
});
|
||||
await expect(pluginProviderRow).toBeVisible();
|
||||
await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
|
||||
await openProviderDetail(page, PLUGIN_PROVIDER.name);
|
||||
await expect(page.getByTestId("provider-remove")).toHaveCount(0);
|
||||
await leaveProviderDetail(page);
|
||||
|
||||
// The provider survives a removeProviders patch pointed at it: the
|
||||
// plugin, not config.json, owns the definition.
|
||||
|
||||
@@ -97,7 +97,7 @@ async function hasFocusWithin(locator: Locator): Promise<boolean> {
|
||||
async function expectProviderSettingsVisible(page: Page) {
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Diagnostic", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Diagnostic report" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function exerciseProviderSettingsStack(page: Page) {
|
||||
@@ -109,7 +109,7 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
await expect(page.getByPlaceholder("e.g. openai/gpt-5")).not.toBeVisible({ timeout: 10_000 });
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Diagnostic report" }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
@@ -118,7 +118,7 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectProviderSettingsVisible(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
await expect(page.getByText("Checked just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
expectProviderNotOnHost,
|
||||
expectProviderPageLoaded,
|
||||
gotoProviderPage,
|
||||
openSettingsHost,
|
||||
openHostSection,
|
||||
expectHostLabelDisplayed,
|
||||
@@ -262,6 +265,20 @@ test.describe("Settings host page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("provider page from its URL", () => {
|
||||
test("loads a provider's page on a cold visit", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await gotoProviderPage(page, getServerId(), "claude");
|
||||
await expectProviderPageLoaded(page, "Claude");
|
||||
});
|
||||
|
||||
test("says a provider the host doesn't have isn't on this host", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await gotoProviderPage(page, getServerId(), "not-a-provider");
|
||||
await expectProviderNotOnHost(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("restart recovery defaults off and persists opt-in and custom prompt", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
@@ -6,20 +7,31 @@ import {
|
||||
buildSettingsRoute,
|
||||
buildSettingsSectionRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
closeCompactSettings,
|
||||
captureCompactSidebarReturn,
|
||||
openSettingsFromCompactSidebar,
|
||||
captureCompactSidebarContext,
|
||||
expectCompactSidebarOpen,
|
||||
closeCompactSidebar,
|
||||
scrollSettingsSidebarToEnd,
|
||||
revealSettingsSidebarSection,
|
||||
expectSettingsSidebarScrollSettled,
|
||||
scrollSettingsSidebarUp,
|
||||
changeSettingsInterfaceFontSize,
|
||||
selectSettingsHostSectionsRapidly,
|
||||
expectSettingsSidebarScrollOffset,
|
||||
scrollSettingsDetailDown,
|
||||
expectSettingsDetailScrollOffset,
|
||||
openSettingsSection,
|
||||
changeSettingsSyntaxTheme,
|
||||
revealSettingsSyntaxTheme,
|
||||
expectSettingsSyntaxColorChanged,
|
||||
changeSettingsTypography,
|
||||
normalizeSettingsFontSizes,
|
||||
changeSettingsInterfaceFontSizeKeepingSidebarPosition,
|
||||
expectSettingsHeader,
|
||||
openAddHostFlow,
|
||||
selectHostConnectionType,
|
||||
@@ -84,13 +96,32 @@ test.describe("Settings sidebar scroll position", () => {
|
||||
await expectSettingsSidebarScrollOffset(page, 0);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position through interface font changes", async ({ page }) => {
|
||||
test("updates appearance without losing the detail scroll or form edits", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
const offset = await scrollSettingsSidebarToEnd(page);
|
||||
await changeSettingsInterfaceFontSize(page, 20);
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
const { offset, keywordColor } = await revealSettingsSyntaxTheme(page);
|
||||
await changeSettingsSyntaxTheme(page, "Dracula");
|
||||
await expectSettingsDetailScrollOffset(page, offset);
|
||||
await expectSettingsSyntaxColorChanged(page, keywordColor);
|
||||
await changeSettingsTypography(page);
|
||||
});
|
||||
|
||||
test("shows normalized font sizes after committing out-of-range and empty values", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await normalizeSettingsFontSizes(page);
|
||||
});
|
||||
|
||||
test("keeps the sidebar context through interface font changes", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await scrollSettingsSidebarToEnd(page);
|
||||
const offset = await changeSettingsInterfaceFontSizeKeepingSidebarPosition(page, 20);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
@@ -350,6 +381,87 @@ test.describe("Settings sidebar navigation", () => {
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("returning from Settings preserves the open sidebar from the first paint", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-sidebar-continuity-" });
|
||||
for (let i = 0; i < 12; i++) await withWorkspace({ prefix: "settings-sidebar-scroll-" });
|
||||
await openWorkspace(page, workspace);
|
||||
await openCompactSidebar(page);
|
||||
await expectCompactSidebarOpen(page);
|
||||
const expectContextPreserved = await captureCompactSidebarContext(page);
|
||||
const openingFrames = await captureCompactSidebarReturn(page, () =>
|
||||
openSettingsFromCompactSidebar(page),
|
||||
);
|
||||
await writeFile(
|
||||
testInfo.outputPath("sidebar-opening-frames.json"),
|
||||
JSON.stringify(openingFrames, null, 2),
|
||||
);
|
||||
expect(openingFrames.some((frame) => frame.settingsVisible)).toBe(true);
|
||||
expect(openingFrames.every((frame) => frame.sidebarVisible || frame.settingsVisible)).toBe(
|
||||
true,
|
||||
);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await goBackInSettings(page);
|
||||
const frames = await captureCompactSidebarReturn(page, () => closeCompactSettings(page));
|
||||
await writeFile(
|
||||
testInfo.outputPath("sidebar-return-frames.json"),
|
||||
JSON.stringify(frames, null, 2),
|
||||
);
|
||||
await testInfo.attach("sidebar-return-frames", {
|
||||
body: JSON.stringify(frames, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
const workspaceFrames = frames.filter((frame) => !frame.pathname.startsWith("/settings"));
|
||||
expect(workspaceFrames.length).toBeGreaterThan(0);
|
||||
expect(workspaceFrames.every((frame) => frame.sidebarVisible)).toBe(true);
|
||||
const firstX = workspaceFrames[0]!.closeX;
|
||||
expect(
|
||||
workspaceFrames.every((frame) => Math.abs((frame.closeX ?? Infinity) - firstX!) < 1),
|
||||
).toBe(true);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await expectContextPreserved();
|
||||
await openSettingsFromCompactSidebar(page);
|
||||
const backFrames = await captureCompactSidebarReturn(page, async () => {
|
||||
await page.goBack();
|
||||
});
|
||||
await writeFile(
|
||||
testInfo.outputPath("browser-back-frames.json"),
|
||||
JSON.stringify(backFrames, null, 2),
|
||||
);
|
||||
await testInfo.attach("browser-back-frames", {
|
||||
body: JSON.stringify(backFrames, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
const backWorkspaceFrames = backFrames.filter(
|
||||
(frame) => !frame.pathname.startsWith("/settings"),
|
||||
);
|
||||
expect(backWorkspaceFrames.length).toBeGreaterThan(0);
|
||||
expect(backWorkspaceFrames.every((frame) => frame.sidebarVisible)).toBe(true);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await closeCompactSidebar(page);
|
||||
});
|
||||
|
||||
test("widening during Settings discards the mobile sidebar return", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-sidebar-resize-" });
|
||||
await openWorkspace(page, workspace);
|
||||
await openCompactSettings(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await page.setViewportSize({ width: 1000, height: 844 });
|
||||
await expect(page.getByTestId("settings-detail-pane")).toBeVisible();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
await closeCompactSettings(page);
|
||||
await expect(page.getByRole("button", { name: "Close sidebar", exact: true })).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeInViewport();
|
||||
});
|
||||
|
||||
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-tab-switcher-",
|
||||
@@ -377,6 +489,7 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
|
||||
await closeCompactSettings(page);
|
||||
await closeCompactSidebar(page);
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
await switcher.click();
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "../support/helpers/usage-login";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
closeUsage,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
@@ -79,12 +80,17 @@ function twoHoursAgo(): string {
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
async function qaScreenshot(page: Page, name: string) {
|
||||
async function qaScreenshot(page: Page, name: string, target: "page" | "summary" = "page") {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
const file = path.join(directory, `${name}.png`);
|
||||
if (target === "summary") {
|
||||
await usageItem(page).screenshot({ path: file });
|
||||
return;
|
||||
}
|
||||
await page.screenshot({ path: file });
|
||||
}
|
||||
|
||||
function hostFilter(page: Page) {
|
||||
@@ -628,6 +634,15 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toBeVisible();
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await qaScreenshot(page, `usage-error-detail-${compact ? "compact" : "desktop"}`);
|
||||
await closeUsage(page);
|
||||
await qaScreenshot(page, `usage-error-summary-${compact ? "compact" : "desktop"}`, "summary");
|
||||
await openUsageFromItem(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
@@ -639,6 +654,8 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: No active quota");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
@@ -647,6 +664,9 @@ for (const compact of [false, true]) {
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43% used");
|
||||
await expectPinnedUsage(page, ["43% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
await expect(usageItem(page)).not.toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
openUsageFromItemWithPressFeedback,
|
||||
pinRow,
|
||||
scrollUsageToWindow,
|
||||
setSummaryInSidebar,
|
||||
@@ -197,7 +198,7 @@ test.describe("Usage item", () => {
|
||||
});
|
||||
|
||||
await test.step("the summary opens the same sheet", async () => {
|
||||
await openUsageFromItem(page);
|
||||
await openUsageFromItemWithPressFeedback(page);
|
||||
await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-summary-sheet");
|
||||
|
||||
@@ -136,7 +136,7 @@ async function expectForkFailureWithoutOverlappingStatus(page: Page) {
|
||||
async function observeToastReplacement(page: Page) {
|
||||
const observation = await page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "Reconnecting to host" })
|
||||
.filter({ hasText: "Reconnecting" })
|
||||
.evaluateHandle((toast) => {
|
||||
const frames: Array<{ connected: boolean; opacity: number; transform: string }> = [];
|
||||
let frame = 0;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
// The browser owns Cmd+Shift+brackets for its own tabs, so web keeps
|
||||
// Alt+Shift+brackets on every OS. Desktop bindings live in the desktop suite.
|
||||
test("web switches to the previous and next tab with Alt+Shift+[ and ]", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "tab-relative-web-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const terminalTabs = page
|
||||
.locator('[data-testid^="workspace-tab-terminal_"]')
|
||||
.filter({ visible: true });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs).toHaveCount(2, { timeout: 30_000 });
|
||||
const first = terminalTabs.nth(0);
|
||||
const second = terminalTabs.nth(1);
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Alt+Shift+BracketLeft");
|
||||
await expect(first).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Alt+Shift+BracketRight");
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
readReactRenders,
|
||||
recordReactRenders,
|
||||
resetReactRenders,
|
||||
} from "../support/helpers/react-renders";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
async function createMockAgent(workspace: SeededWorkspace, title: string): Promise<string> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
test.describe("Updates to agents in other workspaces", () => {
|
||||
let open: SeededWorkspace;
|
||||
let other: SeededWorkspace;
|
||||
|
||||
test.afterEach(async () => {
|
||||
await open?.cleanup();
|
||||
await other?.cleanup();
|
||||
});
|
||||
|
||||
test("do not re-render the open workspace screen", async ({ page }) => {
|
||||
open = await seedWorkspace({ repoPrefix: "unrelated-update-open-", title: "Open" });
|
||||
other = await seedWorkspace({ repoPrefix: "unrelated-update-other-", title: "Other" });
|
||||
const openAgentId = await createMockAgent(open, "Open agent");
|
||||
const otherAgentId = await createMockAgent(other, "Other agent");
|
||||
|
||||
await recordReactRenders(page);
|
||||
await openAgentRoute(page, { workspaceId: open.workspaceId, agentId: openAgentId });
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${openAgentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await page.waitForTimeout(2_000);
|
||||
await resetReactRenders(page);
|
||||
|
||||
await other.client.sendAgentMessage(otherAgentId, "update");
|
||||
await other.client.waitForFinish(otherAgentId, 60_000);
|
||||
// The favicon follows every agent on the host, so it renders when the other agent runs and
|
||||
// settles. That confirms the updates reached this page.
|
||||
await expect
|
||||
.poll(async () => (await readReactRenders(page)).FaviconStatusSync ?? 0)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const renders = await readReactRenders(page);
|
||||
expect(renders.WorkspaceScreenContent ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ env APP_ID=sh.paseo.debug
|
||||
|
||||
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
|
||||
# unselected PINNED workspace row. Ordinary workspace rows are not draggable.
|
||||
# Hold past drag arming (180ms), then release.
|
||||
# Hold past press feedback (130ms) and drag arming (180ms), then release.
|
||||
open "${APP_ID}"
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"sidebar-close\"" 10000
|
||||
@@ -12,7 +12,7 @@ press "id=\"sidebar-close\""
|
||||
wait "label=\"Open menu\"" 5000
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"${WORKSPACE_ROW}\"" 10000
|
||||
longpress "id=\"${WORKSPACE_ROW}\"" 250
|
||||
longpress "id=\"${WORKSPACE_ROW}\"" 400
|
||||
press "id=\"${WORKSPACE_ROW}\""
|
||||
# A released drag must not swallow the next tap: navigation closes the sidebar.
|
||||
wait "label=\"Open menu\"" 5000
|
||||
|
||||
@@ -85,6 +85,20 @@ agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \
|
||||
--artifacts-dir /tmp/paseo-model-tablet-qa
|
||||
```
|
||||
|
||||
`model-tablet-profile.android.ad` taps the `+` of a model row in New workspace, asserts the seeded
|
||||
profile form opens, and asserts that closing the form leaves the remembered model selected. Run it
|
||||
on the same tablet setup, after `model-tablet.android.ad` or with `MODEL_LABEL` remembered, against a
|
||||
host with no agent profiles: with a profile saved, the picker opens on its root view, which has no
|
||||
Back. Set `PROFILE_MODEL_ID` to a different model on the provider's first page:
|
||||
|
||||
```sh
|
||||
agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet-profile.android.ad \
|
||||
--serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \
|
||||
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
|
||||
--env PROVIDER_ID=codex --env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" \
|
||||
--env PROFILE_MODEL_ID="$MODEL_QA_PROFILE_MODEL_ID"
|
||||
```
|
||||
|
||||
The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open;
|
||||
a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent
|
||||
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Connected host with MODEL_LABEL selected and no agent profiles, 1600x2560 at density 320. See README.md.
|
||||
# FORM_URL=paseo://new?serverId=<serverId>; PROFILE_MODEL_ID is another catalog model with no profile.
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
env PROVIDER_ID=codex
|
||||
|
||||
open "${APP_ID}" "${FORM_URL}"
|
||||
wait "label=\"Workspace project\"" 20000
|
||||
press "id=\"combined-model-selector\""
|
||||
wait "id=\"combobox-desktop-container\"" 10000
|
||||
# A remembered model opens its provider's list. Return to the provider page first.
|
||||
press "label=\"Back\""
|
||||
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
|
||||
press "id=\"model-provider-${PROVIDER_ID}\""
|
||||
wait "id=\"compact-model-list\"" 3000
|
||||
# The row's profile action sits inside Android's independent scroll viewport.
|
||||
press "id=\"model-create-profile-${PROVIDER_ID}-${PROFILE_MODEL_ID}\""
|
||||
wait "text=\"Add agent profile\"" 5000
|
||||
# Closing the form keeps the remembered model: the tap did not also select the row.
|
||||
back
|
||||
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
|
||||
close
|
||||
@@ -0,0 +1,83 @@
|
||||
// Record a sidebar toast through timeout on the 2560×1600 Android tablet.
|
||||
// Pass a vertical sample line through the card's plain interior, away from text,
|
||||
// icons, and Android's clipboard preview, plus the visible-to-dismissed interval:
|
||||
// node .../toast-dismissal.mjs recording.mp4 sample-x start-seconds end-seconds
|
||||
// This checks actual native frames; a browser layout test misses this regression.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , recording, sampleX, start, end] = process.argv;
|
||||
assert(recording && sampleX && start && end, "Pass recording, sample x, start and end seconds");
|
||||
const { frames } = JSON.parse(
|
||||
execFileSync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v",
|
||||
"-show_entries",
|
||||
"frame=best_effort_timestamp_time",
|
||||
"-of",
|
||||
"json",
|
||||
recording,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
),
|
||||
);
|
||||
const selectedFrames = frames
|
||||
.map((frame, index) => ({ time: Number(frame.best_effort_timestamp_time), index }))
|
||||
.filter(({ time }) => time >= Number(start) && time <= Number(end));
|
||||
assert(selectedFrames.length > 2, "Capture at least three frames in the supplied interval");
|
||||
const firstFrame = selectedFrames[0].index;
|
||||
const endFrame = selectedFrames.at(-1).index + 1;
|
||||
const bytesPerFrame = 800;
|
||||
// Bound both output and buffer to the requested frames, even for a long recording.
|
||||
const pixels = execFileSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-v",
|
||||
"fatal",
|
||||
"-i",
|
||||
recording,
|
||||
"-vf",
|
||||
`trim=start_frame=${firstFrame}:end_frame=${endFrame},crop=2:400:${Number(sampleX)}:1100,format=gray`,
|
||||
"-fps_mode",
|
||||
"passthrough",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-",
|
||||
],
|
||||
{ maxBuffer: selectedFrames.length * bytesPerFrame + 1024 },
|
||||
);
|
||||
assert.equal(
|
||||
pixels.length,
|
||||
selectedFrames.length * bytesPerFrame,
|
||||
"Every selected frame must be decoded",
|
||||
);
|
||||
const positions = [];
|
||||
let disappeared = false;
|
||||
for (const [index, { time }] of selectedFrames.entries()) {
|
||||
const column = pixels.subarray(index * bytesPerFrame, (index + 1) * bytesPerFrame);
|
||||
const runs = [];
|
||||
let run = [];
|
||||
for (let y = 0; y < 400; y++) {
|
||||
if (column[y * 2] > 250) run.push(y);
|
||||
else {
|
||||
if (run.length > 40) runs.push(run);
|
||||
run = [];
|
||||
}
|
||||
}
|
||||
if (run.length > 40) runs.push(run);
|
||||
runs.sort((a, b) => b.length - a.length);
|
||||
if (runs.length) {
|
||||
const card = runs[0];
|
||||
positions.push({ time, centre: 1100 + (card[0] + card.at(-1)) / 2 });
|
||||
} else if (positions.length) disappeared = true;
|
||||
}
|
||||
assert(positions.length > 2, "Capture at least three visible card frames");
|
||||
assert(disappeared, "Recording must include the card disappearing");
|
||||
const centres = positions.map(({ centre }) => centre);
|
||||
const shift = Math.max(...centres) - Math.min(...centres);
|
||||
console.log(JSON.stringify({ positions, maximumShiftPixels: shift }, null, 2));
|
||||
assert(shift <= 2, `Toast moved ${shift}px during dismissal`);
|
||||
@@ -505,10 +505,14 @@ export async function expectCreateProfileFromModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
const action = pickerViewport(page).getByTestId(
|
||||
`model-create-profile-${input.provider}-${input.modelId}`,
|
||||
);
|
||||
// Until its row is hovered the plus is invisible, so a tap on its spot reaches the row.
|
||||
await expect(action).toHaveCSS("pointer-events", "none");
|
||||
await modelRow(page, input).hover();
|
||||
await pickerViewport(page)
|
||||
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
|
||||
.click();
|
||||
await expect(action).toHaveCSS("pointer-events", "auto");
|
||||
await action.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/** The tones a composer toolbar control paints: its glyph's stroke and its label's text color. */
|
||||
export interface ComposerControlTone {
|
||||
/** The visible glyph's stroke, or null when the control paints no glyph. */
|
||||
glyph: string | null;
|
||||
/** The visible label's color, or null when the control is icon-only. */
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export function composerControl(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the colors the control actually paints. The toolbar glyph and its label are separate
|
||||
* nodes, so this is the only way to tell whether they match.
|
||||
*/
|
||||
export async function readComposerControlTone(control: Locator): Promise<ComposerControlTone> {
|
||||
return control.evaluate((element) => {
|
||||
const visible = (node: Element) => node.getClientRects().length > 0;
|
||||
const glyph = Array.from(element.querySelectorAll("svg")).find(visible);
|
||||
const label = Array.from(element.querySelectorAll("*"))
|
||||
.filter(visible)
|
||||
.find((node) =>
|
||||
[...node.childNodes].some(
|
||||
(child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim(),
|
||||
),
|
||||
);
|
||||
return {
|
||||
glyph: glyph ? getComputedStyle(glyph).stroke : null,
|
||||
label: label ? getComputedStyle(label).color : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function expectGlyphMatchesLabel(tone: ComposerControlTone, name: string): void {
|
||||
expect(tone.label, `${name} paints a label`).not.toBeNull();
|
||||
expect(tone.glyph, `${name}'s glyph matches its label`).toBe(tone.label);
|
||||
}
|
||||
|
||||
export function expectIconOnlyGlyph(tone: ComposerControlTone, name: string): void {
|
||||
expect(tone.label, `${name} is icon-only`).toBeNull();
|
||||
expect(tone.glyph, `${name} paints a glyph`).not.toBeNull();
|
||||
}
|
||||
|
||||
/** The color the browser paints in a text input's placeholder. */
|
||||
export async function readComposerPlaceholderTone(input: Locator): Promise<string | null> {
|
||||
return input.evaluate((element) => getComputedStyle(element, "::placeholder").color);
|
||||
}
|
||||
|
||||
/** The color the browser paints for a text node. */
|
||||
export async function readComposerTextTone(text: Locator): Promise<string | null> {
|
||||
return text.evaluate((element) => getComputedStyle(element).color);
|
||||
}
|
||||
@@ -142,6 +142,8 @@ export async function createCreationScenario(page: Page) {
|
||||
const requests = await observeCreationRequests(page);
|
||||
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
|
||||
let workspaceId = project.workspaceId;
|
||||
// Workspaces the scenario opens itself to evict the deck; they are not creations under test.
|
||||
const evictionWorkspaceIds = new Set<string>();
|
||||
return {
|
||||
cleanup: project.cleanup,
|
||||
async openWorkspaceForm(isolation: "local" | "worktree") {
|
||||
@@ -232,7 +234,9 @@ export async function createCreationScenario(page: Page) {
|
||||
await requests.settled();
|
||||
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
workspace.projectId === project.projectId &&
|
||||
workspace.id !== project.workspaceId &&
|
||||
!evictionWorkspaceIds.has(workspace.id),
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
workspaceId = workspaces[0]!.id;
|
||||
@@ -276,6 +280,7 @@ export async function createCreationScenario(page: Page) {
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
});
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
|
||||
evictionWorkspaceIds.add(result.workspace.id);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await searchModels(page, id);
|
||||
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
|
||||
await expect(remove).toBeInViewport({ ratio: 1 });
|
||||
await remove.click();
|
||||
@@ -33,8 +33,20 @@ export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await searchModels(page, id);
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the model list and scrolls the sheet down to the custom models, since other sections push
|
||||
* them below the fold at phone width. Only the section heading is scrolled into view: scrolling the
|
||||
* row itself would also slide its card sideways and hide a Remove button pushed past the right edge.
|
||||
*/
|
||||
async function searchModels(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await page
|
||||
.getByText("Custom models", { exact: true })
|
||||
.evaluate((heading) => heading.scrollIntoView({ block: "start" }));
|
||||
}
|
||||
|
||||
@@ -46,3 +46,12 @@ export async function downloadFileFromExplorer(page: Page, fileName: string): Pr
|
||||
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
export async function downloadFileFromPreview(page: Page): Promise<Download> {
|
||||
await expect(page.getByText("Binary preview unavailable", { exact: true })).toBeVisible();
|
||||
const button = page.getByRole("button", { name: "Download", exact: true });
|
||||
await expect(button).toBeVisible();
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await button.click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,17 @@ export function setupModal(page: Page): Locator {
|
||||
return page.getByTestId("onboarding-modal").getByRole("dialog");
|
||||
}
|
||||
|
||||
/** A sheet opened from a setup step, which should sit exactly on the setup card. */
|
||||
export function setupSheet(page: Page, testID: "provider-settings-sheet" | "add-provider-sheet") {
|
||||
return page.getByTestId(testID).getByRole("dialog");
|
||||
}
|
||||
|
||||
export async function expectSheetCoversSetupCard(page: Page, sheet: Locator): Promise<void> {
|
||||
await expect(sheet).toBeVisible();
|
||||
const card = await setupModal(page).boundingBox();
|
||||
await expect.poll(() => sheet.boundingBox()).toEqual(card);
|
||||
}
|
||||
|
||||
export async function expectSetupStep(page: Page, title: string): Promise<void> {
|
||||
await expect(setupModal(page).getByRole("heading", { name: title, exact: true })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
|
||||
@@ -106,10 +106,12 @@ export async function verifyProviderSettings({
|
||||
await test.step("provider settings render the registered icon", async () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: "Direct provider example provider details", exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
// The row's button lies under its content, so the icon is in the row, beside the button.
|
||||
const opener = page.getByRole("button", {
|
||||
name: "Direct provider example provider details",
|
||||
exact: true,
|
||||
});
|
||||
await expectProviderIcon(page.getByTestId(/^provider-row-/).filter({ has: opener }), iconPaths);
|
||||
await capture(page, testInfo, "provider-settings");
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function measureRelayRestartDuringStream(input: {
|
||||
agentTitle: string;
|
||||
}): Promise<RelayDeploymentMeasurements> {
|
||||
const { page, relay, agentTitle } = input;
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting" });
|
||||
const beforeOutage = await latestAssistantText(page);
|
||||
await waitForAssistantTextToGrow(page, beforeOutage);
|
||||
|
||||
|
||||
@@ -106,6 +106,33 @@ export async function changeSettingsInterfaceFontSize(page: Page, size: number):
|
||||
await input.fill(String(size));
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(String(size));
|
||||
await expect(
|
||||
page.getByRole("navigation", { name: "Settings" }).getByText("Appearance", { exact: true }),
|
||||
).toHaveCSS("font-size", `${size}px`);
|
||||
}
|
||||
|
||||
export async function changeSettingsInterfaceFontSizeKeepingSidebarPosition(
|
||||
page: Page,
|
||||
size: number,
|
||||
): Promise<number> {
|
||||
const navigation = page.getByRole("navigation", { name: "Settings" });
|
||||
const anchor = navigation.getByRole("button", { name: "About", exact: true });
|
||||
const before = await anchor.evaluate((node) => ({
|
||||
top: node.getBoundingClientRect().top,
|
||||
height: node.getBoundingClientRect().height,
|
||||
}));
|
||||
await changeSettingsInterfaceFontSize(page, size);
|
||||
// Larger fonts reflow rows. Preserve the visible destination, rather than its old pixel offset.
|
||||
await expect
|
||||
.poll(() =>
|
||||
anchor.evaluate((node, top) => Math.abs(node.getBoundingClientRect().top - top), before.top),
|
||||
)
|
||||
.toBeLessThan(before.height);
|
||||
const offset = await page
|
||||
.getByTestId("settings-sidebar-scroll-body")
|
||||
.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return offset;
|
||||
}
|
||||
|
||||
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
|
||||
@@ -480,8 +507,8 @@ export async function serveJson(page: Page, url: string, body: unknown): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAddProviderArea(page: Page): Promise<void> {
|
||||
await page.getByTestId("host-page-add-provider-card").scrollIntoViewIfNeeded();
|
||||
export async function openAddProvider(page: Page): Promise<void> {
|
||||
await page.getByTestId("provider-list-add").click();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -493,17 +520,59 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
|
||||
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await findAcpCatalogProvider(page, providerName);
|
||||
await page.getByRole("button", { name: `Add ${providerName}`, exact: true }).click();
|
||||
// The sheet closes once the add is done. Adding probes the new provider, which can outlast an
|
||||
// assertion's default wait.
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Adding opens the new provider's page; its row waits in the list behind it. */
|
||||
export async function expectProviderInstalledInSettings(
|
||||
page: Page,
|
||||
providerName: string,
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("provider-detail-title")).toHaveText(providerName);
|
||||
await page.getByTestId("provider-detail-back").click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${providerName} provider details`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openProviderDetail(page: Page, providerName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `${providerName} provider details`, exact: true }).click();
|
||||
await expect(page.getByTestId("provider-detail-title")).toHaveText(providerName);
|
||||
}
|
||||
|
||||
/** Load a provider's Settings page from its URL, as a reload or a shared link does. */
|
||||
export async function gotoProviderPage(
|
||||
page: Page,
|
||||
serverId: string,
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
await page.goto(
|
||||
`/settings/hosts/${encodeURIComponent(serverId)}/providers/${encodeURIComponent(providerId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectProviderPageLoaded(page: Page, providerName: string): Promise<void> {
|
||||
await expect(page.getByTestId("provider-detail-title")).toHaveText(providerName, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Provider status")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectProviderNotOnHost(page: Page): Promise<void> {
|
||||
await expect(page.getByText("This provider isn't on this host")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function leaveProviderDetail(page: Page): Promise<void> {
|
||||
await page.getByTestId("provider-detail-back").click();
|
||||
await expect(page.getByTestId("provider-list")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostNoDaemonLifecycleRow(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||
}
|
||||
@@ -613,3 +682,192 @@ export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
|
||||
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function revealSettingsSyntaxTheme(page: Page) {
|
||||
await page.getByLabel(/Highlight theme:/).scrollIntoViewIfNeeded();
|
||||
const offset = await page
|
||||
.getByTestId("settings-detail-scroll-body")
|
||||
.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
const keywordColor = await page
|
||||
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
|
||||
.getByText("export", { exact: true })
|
||||
.first()
|
||||
.evaluate((node) => getComputedStyle(node).color);
|
||||
return { offset, keywordColor };
|
||||
}
|
||||
|
||||
export async function changeSettingsSyntaxTheme(page: Page, theme: string): Promise<void> {
|
||||
await page.getByLabel(/Highlight theme:/).click();
|
||||
await page.getByRole("menuitem", { name: theme, exact: true }).click();
|
||||
await expect(page.getByLabel(`Highlight theme: ${theme}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSettingsSyntaxColorChanged(
|
||||
page: Page,
|
||||
previousColor: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
|
||||
.getByText("export", { exact: true })
|
||||
.first(),
|
||||
).not.toHaveCSS("color", previousColor);
|
||||
}
|
||||
|
||||
export async function changeSettingsTypography(page: Page): Promise<void> {
|
||||
const input = page.getByRole("textbox", { name: "Interface font size", exact: true });
|
||||
await input.fill("18");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue("18");
|
||||
await expect(input).toHaveCSS("font-size", "18px");
|
||||
await expect(page.getByText("Code size", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
const navigation = page.getByRole("navigation", { name: "Settings" });
|
||||
await expect(navigation.getByText("Appearance", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
await expect(navigation.getByText("Providers", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
await expect(page.getByText("Dracula", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
const familyInput = page.getByRole("textbox", { name: "Code font family", exact: true });
|
||||
await familyInput.fill("Courier New, Courier, monospace");
|
||||
await familyInput.press("Enter");
|
||||
const codeInput = page.getByRole("textbox", { name: "Code font size", exact: true });
|
||||
await codeInput.fill("16");
|
||||
await codeInput.press("Enter");
|
||||
await expect(codeInput).toHaveValue("16");
|
||||
const preview = page.getByRole("img", {
|
||||
name: "Live preview of content typography, syntax theme, and code font",
|
||||
});
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS("font-size", "16px");
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
|
||||
"line-height",
|
||||
"24px",
|
||||
);
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
|
||||
"font-family",
|
||||
/Courier/,
|
||||
);
|
||||
const contentInput = page.getByRole("textbox", { name: "Content font size", exact: true });
|
||||
await contentInput.fill("20");
|
||||
await contentInput.press("Enter");
|
||||
await expect(preview.getByText("Readable content and code preview", { exact: true })).toHaveCSS(
|
||||
"font-size",
|
||||
"20px",
|
||||
);
|
||||
}
|
||||
|
||||
export async function normalizeSettingsFontSizes(page: Page): Promise<void> {
|
||||
for (const [name, minimum, maximum] of [
|
||||
["Interface font size", "10", "21"],
|
||||
["Content font size", "10", "21"],
|
||||
["Code font size", "9", "22"],
|
||||
] as const) {
|
||||
const input = page.getByRole("textbox", { name, exact: true });
|
||||
await input.fill("999");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
// Clamp again when the saved value is already at the maximum.
|
||||
await input.fill("999");
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
await input.fill("");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
await input.fill("1");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(minimum);
|
||||
await input.fill("1");
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(minimum);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample actual paints, including the first workspace frame after Settings. */
|
||||
export async function captureCompactSidebarReturn(page: Page, returnAction: () => Promise<void>) {
|
||||
await page.evaluate(() => {
|
||||
const capture = {
|
||||
frames: [] as {
|
||||
time: number;
|
||||
pathname: string;
|
||||
sidebarVisible: boolean;
|
||||
settingsVisible: boolean;
|
||||
closeX: number | null;
|
||||
}[],
|
||||
stopped: false,
|
||||
};
|
||||
Object.assign(window, { __sidebarFrameCapture: capture });
|
||||
const start = performance.now();
|
||||
function sample() {
|
||||
if (capture.stopped) return;
|
||||
const close = document.querySelector('[data-testid="sidebar-close"]');
|
||||
capture.frames.push({
|
||||
time: performance.now() - start,
|
||||
pathname: location.pathname,
|
||||
settingsVisible:
|
||||
document
|
||||
.querySelector('[data-testid="settings-sidebar"]')
|
||||
?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ?? false,
|
||||
sidebarVisible:
|
||||
(close?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ?? false) &&
|
||||
close!.getBoundingClientRect().x >= 0 &&
|
||||
close!.getBoundingClientRect().right <= innerWidth,
|
||||
closeX: close?.getBoundingClientRect().x ?? null,
|
||||
});
|
||||
requestAnimationFrame(sample);
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
await returnAction();
|
||||
return page.evaluate(async () => {
|
||||
const capture = (
|
||||
window as typeof window & {
|
||||
__sidebarFrameCapture: {
|
||||
frames: {
|
||||
time: number;
|
||||
pathname: string;
|
||||
sidebarVisible: boolean;
|
||||
settingsVisible: boolean;
|
||||
closeX: number | null;
|
||||
}[];
|
||||
stopped: boolean;
|
||||
};
|
||||
}
|
||||
).__sidebarFrameCapture;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1500));
|
||||
capture.stopped = true;
|
||||
return capture.frames;
|
||||
});
|
||||
}
|
||||
|
||||
export async function captureCompactSidebarContext(page: Page) {
|
||||
const sidebar = await page.getByTestId("sidebar-project-workspace-list-scroll").elementHandle();
|
||||
if (!sidebar) throw new Error("Open sidebar has no workspace list");
|
||||
await expect
|
||||
.poll(() => sidebar.evaluate((el) => el.scrollHeight - el.clientHeight))
|
||||
.toBeGreaterThan(0);
|
||||
const offset = await sidebar.evaluate((el) => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
return el.scrollTop;
|
||||
});
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return async () => {
|
||||
expect(await sidebar.evaluate((el) => el.isConnected)).toBe(true);
|
||||
expect(await sidebar.evaluate((el) => el.scrollTop)).toBe(offset);
|
||||
await sidebar.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
export async function expectCompactSidebarOpen(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Close sidebar", exact: true })).toBeInViewport();
|
||||
}
|
||||
|
||||
export async function closeCompactSidebar(page: Page) {
|
||||
await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeInViewport();
|
||||
}
|
||||
|
||||
export async function openSettingsFromCompactSidebar(page: Page) {
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expectCompactSettingsList(page);
|
||||
}
|
||||
|
||||
@@ -200,6 +200,18 @@ export async function openUsageFromItem(page: Page): Promise<void> {
|
||||
await expect(usageModal(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openUsageFromItemWithPressFeedback(page: Page): Promise<void> {
|
||||
const summary = page.getByRole("button", { name: /^Usage:/ });
|
||||
await summary.hover();
|
||||
const hoveredFill = await summary.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await expect(summary).not.toHaveCSS("background-color", hoveredFill);
|
||||
await page.mouse.up();
|
||||
await expect(usageModal(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */
|
||||
export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
|
||||
const body = await usageModal(page).boundingBox();
|
||||
|
||||
@@ -109,18 +109,18 @@ export async function expectReconnectingToastVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting" });
|
||||
await expect(toast).toBeVisible({
|
||||
timeout: options?.timeout ?? 30_000,
|
||||
});
|
||||
await expect(toast).toHaveText("Reconnecting to host");
|
||||
await expect(toast).toHaveText("Reconnecting");
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastGone(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Reconnecting to host" })).toHaveCount(0, {
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Reconnecting" })).toHaveCount(0, {
|
||||
timeout: options?.timeout ?? 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ android {
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName '0.1.0'
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation 'com.facebook.react:react-android'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test:runner:1.6.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity android:name="android.app.Activity" android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.view.KeyEvent
|
||||
import android.widget.EditText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Exercises the real Android window and editable that receive physical keys. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TerminalArrowTest {
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private var activity: Activity? = null
|
||||
|
||||
@After fun finish() {
|
||||
instrumentation.runOnMainSync {
|
||||
activity?.let {
|
||||
HardwareKeyboard.release(it.window)
|
||||
it.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun physicalArrowsDoNotMoveTheTerminalInputCaret() {
|
||||
withInput(terminal = true) { input, host ->
|
||||
for (code in listOf(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN)) {
|
||||
for (modifiers in listOf(0, KeyEvent.META_SHIFT_ON, KeyEvent.META_ALT_ON, KeyEvent.META_CTRL_ON)) {
|
||||
input.setSelection(3)
|
||||
for (repeat in 0..2) {
|
||||
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, code, repeat, modifiers)))
|
||||
assertEquals("Physical arrows must leave the hidden input caret at the end", 3, input.selectionStart)
|
||||
}
|
||||
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_UP, code, 0, modifiers)))
|
||||
}
|
||||
}
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
|
||||
assertEquals("abcx", input.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun physicalArrowsStillEditOtherTextFields() {
|
||||
withInput(terminal = false) { input, host ->
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT))
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_LEFT))
|
||||
assertEquals(2, input.selectionStart)
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
|
||||
assertEquals("abxc", input.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun withInput(terminal: Boolean, check: (EditText, Activity) -> Unit) {
|
||||
val host = instrumentation.startActivitySync(
|
||||
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
activity = host
|
||||
lateinit var input: EditText
|
||||
instrumentation.runOnMainSync {
|
||||
input = EditText(host)
|
||||
if (terminal) input.setTag(com.facebook.react.R.id.view_tag_native_id, "paseo-terminal-input")
|
||||
host.setContentView(input)
|
||||
input.setText("abc")
|
||||
input.requestFocus()
|
||||
input.setSelection(3)
|
||||
HardwareKeyboard.observe(host.window)
|
||||
}
|
||||
instrumentation.waitForIdleSync()
|
||||
instrumentation.runOnMainSync {
|
||||
assertSame("The editable must own window focus before keys are dispatched", input, host.window.currentFocus)
|
||||
check(input, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-5
@@ -10,18 +10,24 @@ import android.view.KeyEvent
|
||||
import android.view.Window
|
||||
import android.widget.EditText
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
import com.facebook.react.R
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.interfaces.ExtraWindowEventListener
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
// Matched against the hidden terminal input's `nativeID`
|
||||
// (TERMINAL_INPUT_NATIVE_ID in terminal-input.native.tsx).
|
||||
private const val TERMINAL_INPUT_NATIVE_ID = "paseo-terminal-input"
|
||||
|
||||
internal object HardwareKeyboard {
|
||||
var module: PaseoHardwareKeyboardModule? = null
|
||||
@Volatile var overlayKeys: Set<String> = emptySet()
|
||||
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
|
||||
val dispatcher = WindowKeyDispatcher<KeyEvent>()
|
||||
private val consumedPresses = mutableSetOf<Int>()
|
||||
private val terminalArrowCodes = setOf("ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight")
|
||||
|
||||
fun observe(window: Window) {
|
||||
if (windows.containsKey(window)) return
|
||||
@@ -38,7 +44,7 @@ internal object HardwareKeyboard {
|
||||
consumedPresses.clear()
|
||||
}
|
||||
|
||||
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
|
||||
fun dispatch(event: KeyEvent, composing: Boolean, terminalFocused: Boolean): Boolean {
|
||||
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
|
||||
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
|
||||
val code = code(event.keyCode)
|
||||
@@ -64,8 +70,12 @@ internal object HardwareKeyboard {
|
||||
))
|
||||
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
|
||||
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
|
||||
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
|
||||
return overlayOwnsKey
|
||||
// Without this the hidden input consumes the arrows as caret movement and
|
||||
// its next text change stops being a plain append. Modifiers stay
|
||||
// forwarded: Alt/Shift+arrow are terminal editing keys.
|
||||
val terminalOwnsKey = terminalFocused && !composing && code in terminalArrowCodes
|
||||
if (overlayOwnsKey || terminalOwnsKey) consumedPresses.add(event.keyCode)
|
||||
return overlayOwnsKey || terminalOwnsKey
|
||||
}
|
||||
|
||||
private fun code(keyCode: Int): String = when (keyCode) {
|
||||
@@ -107,9 +117,15 @@ internal object HardwareKeyboard {
|
||||
|
||||
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val editable = (window.currentFocus as? EditText)?.text
|
||||
val focus = window.currentFocus
|
||||
val editable = (focus as? EditText)?.text
|
||||
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
|
||||
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
|
||||
val terminalFocused = focus?.getTag(R.id.view_tag_native_id) == TERMINAL_INPUT_NATIVE_ID
|
||||
return HardwareKeyboard.dispatcher.dispatch(
|
||||
event,
|
||||
{ HardwareKeyboard.dispatch(event, composing, terminalFocused) },
|
||||
{ previous.dispatchKeyEvent(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
|
||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||
private var isHardwareSubmitEnabled = false
|
||||
private var overlayKeys: Set<String> = []
|
||||
private var claimedShortcuts: [ClaimedShortcut] = []
|
||||
|
||||
/// UIKit spells non-character keys as sentinel input strings rather than as the
|
||||
/// character they would type. JS sends the DOM `code` and the mapping lives
|
||||
@@ -19,6 +20,42 @@ private let namedKeyInputs: [String: String] = [
|
||||
"Enter": "\r",
|
||||
]
|
||||
|
||||
/// A shortcut the window takes from the focused text control: Shift+Tab would
|
||||
/// otherwise insert a tab, and Option+Shift+[ would type a quote.
|
||||
private struct ClaimedShortcut {
|
||||
let input: String
|
||||
let modifierFlags: UIKeyModifierFlags
|
||||
}
|
||||
|
||||
struct ClaimedShortcutRecord: Record {
|
||||
@Field var code: String = ""
|
||||
@Field var shift: Bool = false
|
||||
@Field var alt: Bool = false
|
||||
}
|
||||
|
||||
private let claimableKeyInputs: [String: String] = [
|
||||
"Tab": "\t", "Space": " ", "Enter": "\r", "Escape": UIKeyCommand.inputEscape,
|
||||
"ArrowUp": UIKeyCommand.inputUpArrow, "ArrowDown": UIKeyCommand.inputDownArrow,
|
||||
"ArrowLeft": UIKeyCommand.inputLeftArrow, "ArrowRight": UIKeyCommand.inputRightArrow,
|
||||
"Minus": "-", "Equal": "=", "BracketLeft": "[", "BracketRight": "]", "Backslash": "\\",
|
||||
"Semicolon": ";", "Quote": "'", "Backquote": "`", "Comma": ",", "Period": ".", "Slash": "/",
|
||||
]
|
||||
|
||||
/// UIKit matches a key command against the unmodified character, so
|
||||
/// Option+Shift+[ is registered as "[" with both modifier flags.
|
||||
private func claimedShortcut(_ record: ClaimedShortcutRecord) -> ClaimedShortcut? {
|
||||
let code = record.code
|
||||
let input: String?
|
||||
if code.count == 4 && code.hasPrefix("Key") { input = code.suffix(1).lowercased() }
|
||||
else if code.count == 6 && code.hasPrefix("Digit") { input = String(code.suffix(1)) }
|
||||
else { input = claimableKeyInputs[code] }
|
||||
guard let input else { return nil }
|
||||
var modifierFlags: UIKeyModifierFlags = []
|
||||
if record.shift { modifierFlags.insert(.shift) }
|
||||
if record.alt { modifierFlags.insert(.alternate) }
|
||||
return ClaimedShortcut(input: input, modifierFlags: modifierFlags)
|
||||
}
|
||||
|
||||
/// Whether the first responder is mid-IME-composition.
|
||||
///
|
||||
/// A committing key has to stay out of the way while a CJK candidate is being
|
||||
@@ -62,6 +99,14 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
}
|
||||
}
|
||||
|
||||
Function("setClaimedShortcuts") { (shortcuts: [ClaimedShortcutRecord]) in
|
||||
let claimed = shortcuts.compactMap(claimedShortcut)
|
||||
DispatchQueue.main.async {
|
||||
claimedShortcuts = claimed
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
}
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
|
||||
self.keyboardObservers = []
|
||||
@@ -69,6 +114,7 @@ public class PaseoHardwareKeyboardModule: Module {
|
||||
activeModule = nil
|
||||
isHardwareSubmitEnabled = false
|
||||
overlayKeys = []
|
||||
claimedShortcuts = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +185,10 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
{
|
||||
commands.append(makeSubmitCommand())
|
||||
}
|
||||
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
|
||||
if !isComposingText() {
|
||||
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
|
||||
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -172,6 +221,18 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
return command
|
||||
}
|
||||
|
||||
/// The window's press stream still emits the shortcut; the command only keeps
|
||||
/// the key away from the text control.
|
||||
private func makeClaimedCommand(_ shortcut: ClaimedShortcut) -> UIKeyCommand {
|
||||
let command = UIKeyCommand(
|
||||
input: shortcut.input,
|
||||
modifierFlags: shortcut.modifierFlags,
|
||||
action: #selector(handleHardwareKeyboardShortcut(_:))
|
||||
)
|
||||
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
|
||||
return command
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
|
||||
guard canSubmitCurrentTextInput() else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.16.0-beta.1",
|
||||
"version": "0.16.0-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -81,12 +81,14 @@
|
||||
|
||||
/* Suppress the browser's default focus outline — it appears on mouse
|
||||
clicks and looks out of place against our themed surfaces. Keep a
|
||||
themed ring for keyboard users via :focus-visible. */
|
||||
themed ring for keyboard users via :focus-visible. The ring is the active
|
||||
theme's `ring` color, exposed by Unistyles as a CSS variable; the fallback
|
||||
only shows before the theme mounts. */
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
*:focus-visible {
|
||||
outline: 2px solid #20744a;
|
||||
outline: 2px solid var(--colors-ring, #20744a);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+4
-6
@@ -5,7 +5,7 @@ import equal from "fast-deep-equal";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
|
||||
export const sessionCommandQueryPolicy = {
|
||||
export const agentCommandQueryPolicy = {
|
||||
staleTime: 600_000,
|
||||
gcTime: 1_800_000,
|
||||
refetchOnMount: true,
|
||||
@@ -102,14 +102,12 @@ export function draftAgentCommandsQueryKey(input: {
|
||||
] as const;
|
||||
}
|
||||
|
||||
// Draft commands include project skills read from the checkout. When the checkout moves to
|
||||
// another branch the cached list describes files that are gone, so it is dropped rather than
|
||||
// shown while a refetch runs.
|
||||
export function resetDraftAgentCommandsForCheckout(
|
||||
// Refresh checkout skills after branch changes while keeping the previous catalog usable.
|
||||
export function invalidateDraftAgentCommandsForCheckout(
|
||||
queryClient: QueryClient,
|
||||
input: { serverId: string; cwd: string },
|
||||
): Promise<void> {
|
||||
return queryClient.resetQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
|
||||
return queryClient.invalidateQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
|
||||
}
|
||||
|
||||
export function agentCommandsQueryKey(input: {
|
||||
|
||||
@@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { fetchQueryOptions } from "@/data/query";
|
||||
import {
|
||||
invalidateDraftAgentCommandsForCheckout,
|
||||
agentCommandsQueryKey,
|
||||
agentCommandsQueryRoot,
|
||||
draftAgentCommandsQueryKey,
|
||||
normalizeAgentCommandsCwd,
|
||||
sessionAgentCommandsQueryKey,
|
||||
sessionCommandQueryPolicy,
|
||||
agentCommandQueryPolicy,
|
||||
} from "./cache";
|
||||
|
||||
it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => {
|
||||
@@ -20,8 +21,8 @@ it("reuses fresh commands and keeps stale commands during a shared refresh and f
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
const options = fetchQueryOptions({
|
||||
...sessionCommandQueryPolicy,
|
||||
staleTimeMs: sessionCommandQueryPolicy.staleTime,
|
||||
...agentCommandQueryPolicy,
|
||||
staleTimeMs: agentCommandQueryPolicy.staleTime,
|
||||
dataShape: "value",
|
||||
queryKey: key,
|
||||
retry: false,
|
||||
@@ -124,3 +125,35 @@ describe("agent command query keys", () => {
|
||||
expect(agentCommandsQueryRoot("server-1")).toEqual(["agentCommands", "server-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("retains draft commands while checkout invalidation refreshes the catalog", async () => {
|
||||
const client = new QueryClient();
|
||||
const key = draftAgentCommandsQueryKey({
|
||||
serverId: "s",
|
||||
draftConfig: { provider: "codex", cwd: "/repo" },
|
||||
});
|
||||
client.setQueryData(key, ["old"]);
|
||||
let finish: (commands: string[]) => void = () => {};
|
||||
const refresh = new Promise<string[]>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const observer = new QueryObserver(
|
||||
client,
|
||||
fetchQueryOptions({
|
||||
queryKey: key,
|
||||
dataShape: "value",
|
||||
immutableWhen: () => true,
|
||||
queryFn: () => refresh,
|
||||
}),
|
||||
);
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
const updating = invalidateDraftAgentCommandsForCheckout(client, { serverId: "s", cwd: "/repo" });
|
||||
expect(observer.getCurrentResult().data).toEqual(["old"]);
|
||||
expect(observer.getCurrentResult().isPending).toBe(false);
|
||||
expect(observer.getCurrentResult().isFetching).toBe(true);
|
||||
finish(["new"]);
|
||||
await updating;
|
||||
expect(observer.getCurrentResult().data).toEqual(["new"]);
|
||||
unsubscribe();
|
||||
client.clear();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useShallow } from "zustand/shallow";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
agentCommandsQueryKey,
|
||||
sessionCommandQueryPolicy,
|
||||
agentCommandQueryPolicy,
|
||||
type AgentCommandsDraftConfig,
|
||||
} from "./cache";
|
||||
|
||||
@@ -92,9 +92,8 @@ export function useAgentCommandsQuery({
|
||||
agentCommandProvider: draftConfig?.provider ?? provider,
|
||||
agentCommandCwd: draftConfig?.cwd ?? cwd,
|
||||
},
|
||||
...(draftConfig
|
||||
? { immutableWhen: () => true as const }
|
||||
: { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }),
|
||||
...agentCommandQueryPolicy,
|
||||
staleTimeMs: agentCommandQueryPolicy.staleTime,
|
||||
subscribed: queryEnabled,
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
||||
|
||||
@@ -87,7 +87,10 @@ import {
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { useSettingsRouteEntry } from "@/navigation/settings-presentation";
|
||||
import {
|
||||
useSettingsRouteEntry,
|
||||
useSettingsSidebarPresentation,
|
||||
} from "@/navigation/settings-presentation";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||
@@ -473,12 +476,16 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
viewportWidth,
|
||||
}),
|
||||
});
|
||||
const sidebarPresentation = resolveLeftSidebarPresentation({
|
||||
chromeEnabled,
|
||||
isCompactLayout,
|
||||
isFocusModeEnabled: isWorkspaceFocusModeEnabled,
|
||||
desktopSidebarVisible,
|
||||
});
|
||||
const { presentation: sidebarPresentation, visible: sidebarVisible } =
|
||||
useSettingsSidebarPresentation({
|
||||
visible: chromeEnabled,
|
||||
presentation: resolveLeftSidebarPresentation({
|
||||
chromeEnabled,
|
||||
isCompactLayout,
|
||||
isFocusModeEnabled: isWorkspaceFocusModeEnabled,
|
||||
desktopSidebarVisible,
|
||||
}),
|
||||
});
|
||||
const applyPanelLayout = usePanelStore((state) => state.applyPanelLayout);
|
||||
// Publishes the layout to the store, which owns the panel targets each layout allows.
|
||||
useLayoutEffect(() => {
|
||||
@@ -546,7 +553,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<DesktopWindowControls />
|
||||
<FloatingPanelPortalHost />
|
||||
</AppearanceStyleBoundary>
|
||||
{isCompactLayout ? themedSidebarChrome : null}
|
||||
{isCompactLayout ? (
|
||||
<CompactSidebarHost visible={sidebarVisible} interactive={chromeEnabled}>
|
||||
{themedSidebarChrome}
|
||||
</CompactSidebarHost>
|
||||
) : null}
|
||||
<AppearanceStyleBoundary>
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
@@ -584,6 +595,34 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
);
|
||||
}
|
||||
|
||||
// Keep native scroll ranges and gesture hosts intact while Settings covers the sidebar.
|
||||
function CompactSidebarHost({
|
||||
visible,
|
||||
interactive,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
interactive: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
collapsable={false}
|
||||
style={[
|
||||
layoutStyles.compactSidebarHost,
|
||||
{ opacity: visible ? 1 : 0 },
|
||||
// DOM descendants can override pointer-events:none.
|
||||
isWeb && !visible && { display: "none" },
|
||||
]}
|
||||
pointerEvents={interactive ? "box-none" : "none"}
|
||||
accessibilityElementsHidden={!interactive}
|
||||
importantForAccessibility={interactive ? "auto" : "no-hide-descendants"}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContainerLifetime({
|
||||
children,
|
||||
sidebarGesturesEnabled,
|
||||
@@ -614,14 +653,18 @@ function SidebarChrome({
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isMobileActive = useIsMobilePanelActive("agent-list");
|
||||
const overlay = presentation === "overlay";
|
||||
const active = resolveSidebarActivity(presentation !== "hidden", overlay, isMobileActive);
|
||||
const active = resolveSidebarActivity(
|
||||
presentation !== "hidden" && presentation !== "suspended",
|
||||
overlay,
|
||||
isMobileActive,
|
||||
);
|
||||
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
|
||||
return (
|
||||
<SidebarModelProvider
|
||||
active={active}
|
||||
keyboardNavigationActive={keyboardShortcutsEnabled && hardwareKeyboardPresent}
|
||||
>
|
||||
{mounted ? (
|
||||
{mounted || presentation === "suspended" ? (
|
||||
<LeftSidebar active={active} desktopOverlay={overlay && !isCompactLayout} />
|
||||
) : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
@@ -864,6 +907,8 @@ const ROOT_STACK_SCREEN_OPTIONS = {
|
||||
animation: "none" as const,
|
||||
};
|
||||
const ROOT_STACK_NESTED_NAVIGATOR_SCREENS = ["h/[serverId]"] as const;
|
||||
// Settings owns reactive typography leaves; its scroll and form state survive appearance edits.
|
||||
const ROOT_STACK_SCREEN_OWNED_APPEARANCE = ["settings/[section]"] as const;
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
@@ -871,6 +916,7 @@ function RootStack() {
|
||||
<ThemedStack
|
||||
screenOptions={ROOT_STACK_SCREEN_OPTIONS}
|
||||
nestedNavigatorScreens={ROOT_STACK_NESTED_NAVIGATOR_SCREENS}
|
||||
screenOwnedAppearance={ROOT_STACK_SCREEN_OWNED_APPEARANCE}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
@@ -888,6 +934,7 @@ function RootStack() {
|
||||
<Stack.Screen name="settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/projects/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/projects/[projectId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/providers/[providerId]" />
|
||||
</ThemedStack>
|
||||
);
|
||||
}
|
||||
@@ -1004,6 +1051,7 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
const layoutStyles = StyleSheet.create((theme) => ({
|
||||
compactSidebarHost: { position: "absolute", top: 0, bottom: 0, left: 0, right: 0 },
|
||||
surfaceFill: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
|
||||
|
||||
export default function SettingsHostProviderDetailRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
providerId?: string | string[];
|
||||
}>();
|
||||
const serverId = normalizeProjectSettingsRouteId(params.serverId);
|
||||
const providerId = normalizeProjectSettingsRouteId(params.providerId);
|
||||
const view = useMemo(
|
||||
() => ({ kind: "provider" as const, serverId, providerId }),
|
||||
[providerId, serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SettingsScreen view={view} />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Text } from "react-native";
|
||||
|
||||
export { Text as AppearanceText };
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Text as NativeText } from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
// Web numeric tokens are baked into classes. Subscribe this leaf so withUnistyles
|
||||
// resolves its style prop again without replacing the surrounding form or scroll view.
|
||||
function typographyChanges(theme: Theme) {
|
||||
void theme.fontSize;
|
||||
return {};
|
||||
}
|
||||
|
||||
export const AppearanceText = withUnistyles(NativeText, typographyChanges);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
|
||||
@@ -759,6 +759,56 @@ describe("assistant selection copy inside highlighted code", () => {
|
||||
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps line breaks when a quote selection ends partway through code", () => {
|
||||
const message = mountHighlighted();
|
||||
const quote = document.createElement("div");
|
||||
quote.setAttribute("data-paseo-markdown-tag", "blockquote");
|
||||
quote.textContent = "Quoted context";
|
||||
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(quote);
|
||||
|
||||
const content = copyBetween(
|
||||
[tokenText(message, "Quoted context"), 0],
|
||||
[tokenText(message, " (answer)"), 9],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"> Quoted context\n\n```typescript\nconst answer = 1;\n if (answer)\n```",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps indented code lines inside their list item", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = [
|
||||
'<div data-testid="assistant-message">',
|
||||
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
|
||||
'<div data-paseo-markdown-tag="li">',
|
||||
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
|
||||
'<div><div data-paseo-markdown-tag="p"><span>Item text</span></div>',
|
||||
highlightedCodeBlock("typescript"),
|
||||
"</div></div></div></div>",
|
||||
].join("");
|
||||
document.body.append(host);
|
||||
|
||||
const content = copyBetween([tokenText(host, "1."), 0], [tokenText(host, " (answer)"), 9]);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"1. Item text\n\n ```typescript\n const answer = 1;\n if (answer)\n ```",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps line breaks when a selection starts partway through code", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyBetween(
|
||||
[tokenText(message, " answer"), 1],
|
||||
[tokenText(message, "After the block."), "After the block.".length],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe(
|
||||
"```typescript\nanswer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("semantic library output", () => {
|
||||
|
||||
@@ -81,7 +81,8 @@ turndown.addRule("compactListItem", {
|
||||
const item = content
|
||||
.replace(/^\n+/, "")
|
||||
.replace(/\n+$/, "\n")
|
||||
.replace(/\n(?=\S)/g, "\n ");
|
||||
// Every non-empty line, so indented code lines stay inside the item.
|
||||
.replace(/\n(?=[^\n])/g, "\n ");
|
||||
const parent = node.parentElement;
|
||||
if (parent?.nodeName !== "OL") {
|
||||
return `${options.bulletListMarker} ${item}\n`;
|
||||
@@ -359,6 +360,9 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
|
||||
return true;
|
||||
}
|
||||
if (isCrossBoundaryCodeElement(range, element, tag)) {
|
||||
return true;
|
||||
}
|
||||
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
||||
if (
|
||||
(isSelectableSemantic || element.tagName === "A") &&
|
||||
@@ -386,6 +390,11 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
return hasSelectedAllContents(range, element, tag === "li");
|
||||
}
|
||||
|
||||
function isCrossBoundaryCodeElement(range: Range, element: Element, tag: string | null): boolean {
|
||||
const isCode = tag === "pre" || (tag === "code" && Boolean(element.closest(CODE_BLOCK_SELECTOR)));
|
||||
return isCode && !selectionStaysInsideElement(range, element);
|
||||
}
|
||||
|
||||
function selectionStaysInsideElement(range: Range, element: Element): boolean {
|
||||
return (
|
||||
range.startContainer !== element &&
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
||||
@@ -73,6 +74,7 @@ export interface SheetHeader {
|
||||
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
|
||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFill };
|
||||
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
|
||||
const HEADER_ICON_SIZE = 16;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
nativeDialogSurface: {
|
||||
@@ -137,6 +139,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[SHEET_HEADER_CLOSE_PADDING_SCALE],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
headerIconButtonInactive: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
headerIconBox: {
|
||||
width: HEADER_ICON_SIZE,
|
||||
height: HEADER_ICON_SIZE,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
// Without a header the close button floats over the card's top-right corner.
|
||||
floatingClose: {
|
||||
position: "absolute",
|
||||
@@ -345,31 +356,55 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
|
||||
|
||||
/**
|
||||
* An icon action in a sheet header's trailing rail, drawn like the Close button so a header's
|
||||
* actions line up with it at one size. Pass it through `SheetHeader.actions`.
|
||||
* actions line up with it at one size. Pass it through `SheetHeader.actions`. Like `Button`, a
|
||||
* loading action shows a spinner in place of its icon and, like a disabled one, is dimmed and
|
||||
* cannot be pressed.
|
||||
*/
|
||||
export function SheetHeaderIconButton({
|
||||
icon: Icon,
|
||||
accessibilityLabel,
|
||||
onPress,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
testID,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
accessibilityLabel: string;
|
||||
onPress: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const inactive = disabled || loading;
|
||||
const style = useMemo(
|
||||
() => [styles.closeButton, inactive && styles.headerIconButtonInactive],
|
||||
[inactive],
|
||||
);
|
||||
const accessibilityState = useMemo(
|
||||
() => ({ disabled: inactive, busy: loading }),
|
||||
[inactive, loading],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
style={styles.closeButton}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={inactive}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<Icon size={16} color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
)}
|
||||
{({ pressed }) => {
|
||||
const color = pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
if (!loading) return <Icon size={HEADER_ICON_SIZE} color={color} />;
|
||||
// The spinner sits in the icon's box, so the header does not move while it runs.
|
||||
return (
|
||||
<View style={styles.headerIconBox}>
|
||||
<LoadingSpinner size={HEADER_ICON_SIZE} color={color} />
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -539,6 +574,11 @@ export interface AdaptiveModalSheetProps {
|
||||
* corner, and the compact sheet closes from its handle.
|
||||
*/
|
||||
header?: SheetHeader;
|
||||
/**
|
||||
* Leave out the header's close button when `header.back` is the only way out, as for a sheet
|
||||
* that stands in for a page of the surface beneath it. Escape still closes the sheet.
|
||||
*/
|
||||
showCloseButton?: boolean;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDismiss?: () => void;
|
||||
@@ -569,6 +609,7 @@ export interface AdaptiveModalSheetProps {
|
||||
*/
|
||||
function CompactSheetBody({
|
||||
header,
|
||||
showCloseButton,
|
||||
onClose,
|
||||
testID,
|
||||
footer,
|
||||
@@ -579,6 +620,7 @@ function CompactSheetBody({
|
||||
children,
|
||||
}: {
|
||||
header?: SheetHeader;
|
||||
showCloseButton: boolean;
|
||||
onClose: () => void;
|
||||
testID?: string;
|
||||
footer: ReactNode;
|
||||
@@ -608,7 +650,14 @@ function CompactSheetBody({
|
||||
);
|
||||
return (
|
||||
<SheetVisibleFrame footer={footerView}>
|
||||
{header ? <SheetHeaderView header={header} onClose={onClose} testID={testID} /> : null}
|
||||
{header ? (
|
||||
<SheetHeaderView
|
||||
header={header}
|
||||
onClose={onClose}
|
||||
showCloseButton={showCloseButton}
|
||||
testID={testID}
|
||||
/>
|
||||
) : null}
|
||||
<View style={[styles.compactStaticContent, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
@@ -635,6 +684,7 @@ function CompactSheetBody({
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
header,
|
||||
showCloseButton = true,
|
||||
visible,
|
||||
onClose,
|
||||
onDismiss,
|
||||
@@ -762,6 +812,7 @@ export function AdaptiveModalSheet({
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<CompactSheetBody
|
||||
header={header}
|
||||
showCloseButton={showCloseButton}
|
||||
onClose={onClose}
|
||||
testID={testID}
|
||||
footer={footerView}
|
||||
@@ -781,7 +832,9 @@ export function AdaptiveModalSheet({
|
||||
desktopHeight == null ? styles.desktopStaticContent : styles.compactStaticContent;
|
||||
const cardInner = (
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
{header ? <SheetHeaderView header={header} onClose={onClose} /> : null}
|
||||
{header ? (
|
||||
<SheetHeaderView header={header} onClose={onClose} showCloseButton={showCloseButton} />
|
||||
) : null}
|
||||
<View style={[scrollable ? styles.desktopScrollContainer : desktopStaticStyle, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SidebarViewSlot } from "@/plugins/sidebar-view";
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
CircleGauge,
|
||||
FolderPlus,
|
||||
@@ -85,8 +84,9 @@ import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { useLeftPanelWidth } from "@/mobile-panels/provider";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { openSettingsFromSidebar } from "@/navigation/settings-navigation";
|
||||
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
@@ -157,6 +157,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const activeWorkspace = useActiveWorkspaceSelection();
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
|
||||
const {
|
||||
@@ -193,25 +194,26 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const openProjectPicker = useOpenAddProject();
|
||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
||||
|
||||
// Each destination closes the navigation overlay first. Closing is idempotent, so a docked
|
||||
// sidebar runs the same handler.
|
||||
// Workspace destinations close the overlay; Settings retains it for the return journey.
|
||||
const handleOpenProject = useCallback(() => {
|
||||
void openProjectPicker();
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleSettings = useCallback(() => {
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsRoute());
|
||||
}, [showMobileAgent]);
|
||||
openSettingsFromSidebar({ workspace: activeWorkspace, isCompact: isCompactLayout });
|
||||
}, [activeWorkspace, isCompactLayout]);
|
||||
|
||||
const handleAddHost = useAddHost();
|
||||
|
||||
const handleOpenHostSettings = useCallback(
|
||||
(serverId: string) => {
|
||||
showMobileAgent();
|
||||
openHostOverview(serverId);
|
||||
openSettingsFromSidebar({
|
||||
workspace: activeWorkspace,
|
||||
isCompact: isCompactLayout,
|
||||
route: buildSettingsHostSectionRoute(serverId, "host"),
|
||||
});
|
||||
},
|
||||
[showMobileAgent],
|
||||
[activeWorkspace, isCompactLayout],
|
||||
);
|
||||
|
||||
const labels = useMemo(
|
||||
|
||||
@@ -63,10 +63,25 @@ const html = `<!doctype html>
|
||||
#diagram svg {
|
||||
display: block;
|
||||
}
|
||||
/* Keep Mermaid's temporary render container unscaled when the host mounts this frame
|
||||
at 0x0 or 240x240 before the diagram's own dimensions are known. */
|
||||
#staging-clip {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
#staging {
|
||||
width: 16384px;
|
||||
height: 16384px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="diagram"></div>
|
||||
<div id="staging-clip" aria-hidden="true"><div id="staging"></div></div>
|
||||
<script>${js}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -40,6 +40,9 @@ function initializeMermaid(colorScheme: "light" | "dark"): void {
|
||||
],
|
||||
flowchart: { htmlLabels: false },
|
||||
class: { htmlLabels: false },
|
||||
// A gantt chart takes the width of the element it renders in, which is the oversized staging
|
||||
// container. Keep it as wide as this frame, which on native is the message column.
|
||||
gantt: { useWidth: document.body.clientWidth },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,7 +100,15 @@ function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSi
|
||||
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
|
||||
try {
|
||||
initializeMermaid(message.colorScheme);
|
||||
const { svg } = await mermaid.render(`paseo-mermaid-${message.revision}`, message.source);
|
||||
const staging = document.getElementById("staging");
|
||||
if (!staging) {
|
||||
throw new Error("Missing Mermaid staging container");
|
||||
}
|
||||
const { svg } = await mermaid.render(
|
||||
`paseo-mermaid-${message.revision}`,
|
||||
message.source,
|
||||
staging,
|
||||
);
|
||||
if (message.revision !== latestRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+104
-1
@@ -29,9 +29,15 @@ function waitForRuntimeMessage(
|
||||
});
|
||||
}
|
||||
|
||||
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
|
||||
async function mountRuntime(
|
||||
size?: { width: number; height: number },
|
||||
options?: { allowSameOrigin?: boolean },
|
||||
): Promise<HTMLIFrameElement> {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.sandbox.add("allow-scripts");
|
||||
if (options?.allowSameOrigin) {
|
||||
frame.sandbox.add("allow-same-origin");
|
||||
}
|
||||
if (size) {
|
||||
frame.style.width = `${size.width}px`;
|
||||
frame.style.height = `${size.height}px`;
|
||||
@@ -112,6 +118,103 @@ describe("Mermaid sandbox runtime", () => {
|
||||
expect(renderedSize(narrow)).toEqual(renderedSize(wide));
|
||||
});
|
||||
|
||||
/**
|
||||
* MermaidFenceHost renders the first revision inside a 240x240 measuring frame (or 0x0 before
|
||||
* ResizeObserver settles), then resizes the frame to the reported diagram size without
|
||||
* re-rendering. Diagrams such as xychart-beta set `width="100%"` and `viewBox="0 0 700 500"`
|
||||
* before measuring axis text with `getBoundingClientRect()`, so rendering directly in a narrow
|
||||
* frame shrinks measured glyph boxes and leaves the Y-axis title colliding with tick labels and
|
||||
* X-axis labels clipped at the bottom edge.
|
||||
*/
|
||||
it("preserves unscaled SVG layout when rendered inside a narrow or zero-sized frame", async () => {
|
||||
const source = [
|
||||
"xychart-beta",
|
||||
' title "Throughput by Stage (req/s)"',
|
||||
' x-axis ["v1-base", "v2-parse", "v3-batch", "v4-cache", "v5-simd", "v6-final"]',
|
||||
' y-axis "Throughput (req/s)" 92.5 --> 93.6',
|
||||
" bar [92.7, 92.8, 93.1, 93.2, 93.3, 93.5]",
|
||||
].join("\n");
|
||||
const zeroFrame = await mountRuntime({ height: 0, width: 0 }, { allowSameOrigin: true });
|
||||
const measuringFrame = await mountRuntime(
|
||||
{ height: 240, width: 240 },
|
||||
{ allowSameOrigin: true },
|
||||
);
|
||||
const fullFrame = await mountRuntime({ height: 500, width: 700 }, { allowSameOrigin: true });
|
||||
|
||||
await render(zeroFrame, { revision: 1, source });
|
||||
await render(measuringFrame, { revision: 1, source });
|
||||
await render(fullFrame, { revision: 1, source });
|
||||
|
||||
for (const frame of [zeroFrame, measuringFrame]) {
|
||||
frame.style.width = "700px";
|
||||
frame.style.height = "500px";
|
||||
const doc = frame.contentDocument;
|
||||
if (!doc) {
|
||||
throw new Error("Expected inspectable runtime document");
|
||||
}
|
||||
const svgRect = doc.querySelector("#diagram svg")?.getBoundingClientRect();
|
||||
const yTitleRect = doc.querySelector(".left-axis .title text")?.getBoundingClientRect();
|
||||
const yLabels = Array.from(doc.querySelectorAll(".left-axis .label text"));
|
||||
const xLabels = Array.from(doc.querySelectorAll(".bottom-axis .label text"));
|
||||
expect(svgRect).not.toBeUndefined();
|
||||
expect(yTitleRect).not.toBeUndefined();
|
||||
expect(yLabels.length).toBeGreaterThan(0);
|
||||
expect(xLabels.length).toBeGreaterThan(0);
|
||||
|
||||
for (const label of yLabels) {
|
||||
expect(yTitleRect!.right).toBeLessThan(label.getBoundingClientRect().left);
|
||||
}
|
||||
for (const label of xLabels) {
|
||||
expect(label.getBoundingClientRect().bottom).toBeLessThanOrEqual(svgRect!.bottom);
|
||||
}
|
||||
}
|
||||
|
||||
function axisOrigin(frame: HTMLIFrameElement): { leftAxisX: number; bottomAxisY: number } {
|
||||
const doc = frame.contentDocument;
|
||||
const leftPath = doc?.querySelector(".left-axis .axisl-line path")?.getAttribute("d") ?? "";
|
||||
const bottomPath =
|
||||
doc?.querySelector(".bottom-axis .axis-line path")?.getAttribute("d") ?? "";
|
||||
const leftMatch = leftPath.match(/^M\s+([\d.]+),/);
|
||||
const bottomMatch = bottomPath.match(/^M\s+[\d.]+,([\d.]+)/);
|
||||
if (!leftMatch || !bottomMatch) {
|
||||
throw new Error(`Unexpected axis paths: ${leftPath} | ${bottomPath}`);
|
||||
}
|
||||
return {
|
||||
leftAxisX: Number.parseFloat(leftMatch[1]),
|
||||
bottomAxisY: Number.parseFloat(bottomMatch[1]),
|
||||
};
|
||||
}
|
||||
|
||||
const fullOrigin = axisOrigin(fullFrame);
|
||||
for (const candidate of [measuringFrame, zeroFrame]) {
|
||||
const origin = axisOrigin(candidate);
|
||||
expect(origin.leftAxisX).toBeCloseTo(fullOrigin.leftAxisX, 3);
|
||||
expect(origin.bottomAxisY).toBeCloseTo(fullOrigin.bottomAxisY, 3);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A gantt chart takes the width of the element Mermaid renders it in. Rendering inside the large
|
||||
* offscreen container must keep it as wide as the frame, which on native is the message column.
|
||||
*/
|
||||
it("renders a gantt chart as wide as the frame the host gives it", async () => {
|
||||
const source = [
|
||||
"gantt",
|
||||
" title Release plan",
|
||||
" dateFormat YYYY-MM-DD",
|
||||
" section Build",
|
||||
" Design :a1, 2024-01-01, 10d",
|
||||
" Implement :after a1, 20d",
|
||||
].join("\n");
|
||||
const widths = [];
|
||||
for (const width of [240, 350, 900]) {
|
||||
const frame = await mountRuntime({ height: 240, width });
|
||||
widths.push(renderedSize(await render(frame, { revision: 1, source })).width);
|
||||
}
|
||||
|
||||
expect(widths).toEqual([240, 350, 900]);
|
||||
});
|
||||
|
||||
it("coalesces queued input and never reports an obsolete result", async () => {
|
||||
const frame = await mountRuntime();
|
||||
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
|
||||
|
||||
@@ -106,29 +106,76 @@ function ProviderSettingsAction({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
/**
|
||||
* An icon button that explains itself in a tooltip on desktop hover. Tooltips
|
||||
* never open on mobile, so native renders the shared gesture-aware pressable
|
||||
* instead of a plain Pressable: inside the model list's independent scroll
|
||||
* viewport (Android, wide windows) the native scroll gesture cancels a plain
|
||||
* Pressable's touches and the button would never fire.
|
||||
*/
|
||||
function IconActionButton({
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
onPress,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
onPress: () => void;
|
||||
style?:
|
||||
| StyleProp<ViewStyle>
|
||||
| ((state: PressableStateCallbackType & { hovered?: boolean }) => StyleProp<ViewStyle>);
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (isNative) {
|
||||
return (
|
||||
<ModelBrowserPressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={style}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</ModelBrowserPressable>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
style={style}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("modelSelector.editProfilesLabel")}
|
||||
testID="model-profiles-edit"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
>
|
||||
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
|
||||
{children}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{t("modelSelector.editProfilesLabel")}</Text>
|
||||
<Text style={styles.tooltipText}>{accessibilityLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<IconActionButton
|
||||
onPress={onPress}
|
||||
style={iconButtonStyle}
|
||||
accessibilityLabel={t("modelSelector.editProfilesLabel")}
|
||||
testID="model-profiles-edit"
|
||||
>
|
||||
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
|
||||
</IconActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
const IndependentScrollGestureContext = createContext<ReturnType<typeof Gesture.Native> | null>(
|
||||
null,
|
||||
);
|
||||
@@ -637,24 +684,14 @@ function ModelRowProfileAction({
|
||||
[visible],
|
||||
);
|
||||
return (
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={pressableStyle}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<IconActionButton
|
||||
onPress={onPress}
|
||||
style={pressableStyle}
|
||||
accessibilityLabel={label}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</IconActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1690,8 +1727,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
rowIconButtonPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
// Hidden in style so every pressable branch that applies the style also stops taking taps.
|
||||
profileActionHidden: {
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
emptyState: {
|
||||
padding: theme.spacing[4],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Platform, ScrollView, Text, View } from "react-native";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type LayoutChangeEvent } from "react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { PackagePlus } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { SearchField } from "@/components/ui/search-field";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { rankProviderCatalog } from "@/components/provider-catalog-search";
|
||||
import {
|
||||
useAcpProviderCatalog,
|
||||
type AcpProviderCatalogItem,
|
||||
} from "@/hooks/use-acp-provider-catalog";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { SPACING, type Theme } from "@/styles/theme";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
interface ProviderCatalogListProps {
|
||||
serverId: string;
|
||||
installingProviderId: string | null;
|
||||
onInstall: (entry: AcpProviderCatalogItem) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const PROVIDER_FALLBACK_ICON_SIZE = 20;
|
||||
const PROVIDER_REMOTE_ICON_SIZE = 24;
|
||||
const TILE_GAP = SPACING[3];
|
||||
/** Narrowest tile that keeps the icon, type badge, install link, and Add on one row. */
|
||||
const MIN_TILE_WIDTH = 300;
|
||||
const MAX_COLUMNS = 4;
|
||||
|
||||
const ThemedPackagePlus = withUnistyles(PackagePlus);
|
||||
const ThemedSvgXml = withUnistyles(SvgXml);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
function columnCountForWidth(width: number): number {
|
||||
const fits = Math.floor((width + TILE_GAP) / (MIN_TILE_WIDTH + TILE_GAP));
|
||||
return Math.min(MAX_COLUMNS, Math.max(1, fits));
|
||||
}
|
||||
|
||||
interface CatalogTileProps {
|
||||
entry: AcpProviderCatalogItem;
|
||||
/** `undefined` until the grid is measured, or in a single column; the tile fills the row. */
|
||||
width: number | undefined;
|
||||
installing: boolean;
|
||||
onInstall: (entry: AcpProviderCatalogItem) => void;
|
||||
}
|
||||
|
||||
function CatalogTile({ entry, width, installing, onInstall }: CatalogTileProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleInstall = useCallback(() => {
|
||||
onInstall(entry);
|
||||
}, [entry, onInstall]);
|
||||
const tileStyle = useMemo(
|
||||
() => [settingsStyles.card, styles.tile, width === undefined ? styles.tileFull : { width }],
|
||||
[width],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={tileStyle} testID={`provider-catalog-tile-${entry.id}`}>
|
||||
<View style={styles.iconFrame}>
|
||||
{entry.iconSvg ? (
|
||||
<ThemedSvgXml
|
||||
xml={entry.iconSvg}
|
||||
width={PROVIDER_REMOTE_ICON_SIZE}
|
||||
height={PROVIDER_REMOTE_ICON_SIZE}
|
||||
uniProps={foregroundColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<ThemedPackagePlus size={PROVIDER_FALLBACK_ICON_SIZE} uniProps={foregroundColorMapping} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.details}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{entry.title}
|
||||
</Text>
|
||||
<View style={styles.metaRow}>
|
||||
{/* The catalog is the ACP registry, so every entry it offers is an ACP provider. */}
|
||||
<StatusBadge label={t("providerCatalog.types.acp")} size="xs" />
|
||||
<ExternalLink
|
||||
href={entry.installLink}
|
||||
label={t("providerCatalog.actions.installInstructions")}
|
||||
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
|
||||
provider: entry.title,
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={installing}
|
||||
loading={installing}
|
||||
onPress={handleInstall}
|
||||
accessibilityLabel={t("providerCatalog.actions.addProvider", { provider: entry.title })}
|
||||
style={styles.addButton}
|
||||
testID={`install-provider-${entry.id}`}
|
||||
>
|
||||
{installing ? t("providerCatalog.actions.adding") : t("providerCatalog.actions.add")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderCatalogList({
|
||||
serverId,
|
||||
installingProviderId,
|
||||
onInstall,
|
||||
}: ProviderCatalogListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { entries: catalogEntries } = useAcpProviderCatalog();
|
||||
const { entries: providerEntries } = useProvidersSnapshot(serverId);
|
||||
const [search, setSearch] = useState("");
|
||||
const [gridWidth, setGridWidth] = useState<number | null>(null);
|
||||
|
||||
const installedIds = useMemo(
|
||||
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
|
||||
[providerEntries],
|
||||
);
|
||||
|
||||
const availableEntries = useMemo(
|
||||
() =>
|
||||
rankProviderCatalog(
|
||||
catalogEntries.filter((entry) => !installedIds.has(entry.id)),
|
||||
search,
|
||||
),
|
||||
[catalogEntries, installedIds, search],
|
||||
);
|
||||
|
||||
const handleGridLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setGridWidth(event.nativeEvent.layout.width);
|
||||
}, []);
|
||||
|
||||
const tileWidth = useMemo(() => {
|
||||
if (gridWidth === null) return undefined;
|
||||
const columns = columnCountForWidth(gridWidth);
|
||||
if (columns === 1) return undefined;
|
||||
return Math.floor((gridWidth - TILE_GAP * (columns - 1)) / columns);
|
||||
}, [gridWidth]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.searchRail}>
|
||||
<SearchField
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder={t("providerCatalog.search")}
|
||||
clearAccessibilityLabel={t("providerCatalog.clearSearch")}
|
||||
testID="provider-catalog-search"
|
||||
clearTestID="provider-catalog-search-clear"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{availableEntries.length === 0 ? (
|
||||
<View style={[settingsStyles.card, styles.stateBox]}>
|
||||
<Text style={styles.stateText}>{t("providerCatalog.noProviders")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.grid} onLayout={handleGridLayout}>
|
||||
{availableEntries.map((entry) => (
|
||||
<CatalogTile
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
width={tileWidth}
|
||||
installing={installingProviderId === entry.id}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
searchRail: {
|
||||
flexDirection: "row",
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: TILE_GAP,
|
||||
},
|
||||
tile: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
tileFull: {
|
||||
width: "100%",
|
||||
},
|
||||
details: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
iconFrame: {
|
||||
flexShrink: 0,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
addButton: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
name: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
stateBox: {
|
||||
minHeight: 96,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
stateText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -1,869 +0,0 @@
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
|
||||
import {
|
||||
resolveProviderDiscoveredModels,
|
||||
type ProviderDiscoveredModelsCache,
|
||||
} from "./provider-diagnostic-models";
|
||||
|
||||
interface ProviderDiagnosticSheetProps {
|
||||
provider: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
function rankModels<T>(items: T[], query: string, fields: (item: T) => string[]): T[] {
|
||||
if (!query.trim()) return items;
|
||||
const scored = items
|
||||
.map((item) => ({ item, score: scoreTextFields(query, fields(item)) }))
|
||||
.filter(
|
||||
(entry): entry is { item: T; score: NonNullable<typeof entry.score> } => entry.score !== null,
|
||||
);
|
||||
scored.sort((a, b) => compareMatchScores(a.score, b.score));
|
||||
return scored.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
function DiscoveredModelRow({ model }: { model: AgentModelDefinition }) {
|
||||
return (
|
||||
<View style={sheetStyles.modelRow}>
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
>
|
||||
{model.id}
|
||||
</Text>
|
||||
{model.description ? (
|
||||
<Text style={sheetStyles.descriptionInline} numberOfLines={1}>
|
||||
{model.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomModelRow({
|
||||
model,
|
||||
deleting,
|
||||
onDelete,
|
||||
}: {
|
||||
model: ProviderProfileModel;
|
||||
deleting: boolean;
|
||||
onDelete: (modelId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]);
|
||||
const deleteButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
sheetStyles.iconButton,
|
||||
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
|
||||
deleting ? sheetStyles.disabled : null,
|
||||
],
|
||||
[deleting],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={sheetStyles.modelRow}>
|
||||
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
>
|
||||
{model.id}
|
||||
</Text>
|
||||
<View style={sheetStyles.modelRowFiller} />
|
||||
<Pressable
|
||||
onPress={handleDelete}
|
||||
disabled={deleting}
|
||||
hitSlop={8}
|
||||
style={deleteButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.providers.models.removeModel", { id: model.id })}
|
||||
>
|
||||
<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ title, count, hint }: { title: string; count?: number; hint?: string }) {
|
||||
return (
|
||||
<View style={sheetStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>{title}</Text>
|
||||
<View style={sheetStyles.sectionHeaderMeta}>
|
||||
{count !== undefined ? (
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>{count}</Text>
|
||||
) : null}
|
||||
{count !== undefined && hint ? (
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>·</Text>
|
||||
) : null}
|
||||
{hint ? <Text style={settingsStyles.sectionHeaderTitle}>{hint}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AddCustomModelSubSheet({
|
||||
provider,
|
||||
serverId,
|
||||
visible,
|
||||
onClose,
|
||||
refresh,
|
||||
}: {
|
||||
provider: string;
|
||||
serverId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [input, setInput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const additionalModels = useMemo(
|
||||
() => config?.providers?.[provider]?.additionalModels ?? [],
|
||||
[config?.providers, provider],
|
||||
);
|
||||
const trimmed = input.trim();
|
||||
const canAdd = trimmed.length > 0 && !additionalModels.some((model) => model.id === trimmed);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setInput("");
|
||||
setError(null);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
if (!canAdd) return;
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
void patchConfig({
|
||||
providers: {
|
||||
[provider]: {
|
||||
additionalModels: [...additionalModels, { id: trimmed, label: trimmed }],
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => refresh([provider]))
|
||||
.then(() => onClose())
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : t("settings.providers.models.failedToSave"));
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
}, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, t, trimmed]);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.providers.models.addCustomTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
desktopMaxWidth={420}
|
||||
snapPoints={ADD_SNAP_POINTS}
|
||||
testID="add-custom-model-sheet"
|
||||
>
|
||||
<View style={sheetStyles.formGroup}>
|
||||
<Text style={sheetStyles.formLabel}>{t("settings.providers.models.modelId")}</Text>
|
||||
<AdaptiveTextInput
|
||||
initialValue={input}
|
||||
resetKey={`add-custom-${visible}`}
|
||||
onChangeText={setInput}
|
||||
onSubmitEditing={handleAdd}
|
||||
placeholder={t("settings.providers.models.modelIdPlaceholder")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[sheetStyles.formInput, isWeb && { outlineStyle: "none" }]}
|
||||
/>
|
||||
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
|
||||
<View style={sheetStyles.formActions}>
|
||||
<Button variant="secondary" onPress={onClose} disabled={saving}>
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="default" onPress={handleAdd} disabled={!canAdd || saving}>
|
||||
{saving ? t("settings.providers.models.adding") : t("settings.providers.models.add")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagnosticSubSheet({
|
||||
provider,
|
||||
serverId,
|
||||
visible,
|
||||
onClose,
|
||||
}: {
|
||||
provider: string;
|
||||
serverId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchDiagnostic = useCallback(async () => {
|
||||
if (!client) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await client.getProviderDiagnostic(provider);
|
||||
setDiagnostic(result.diagnostic);
|
||||
} catch (err) {
|
||||
setDiagnostic(
|
||||
err instanceof Error ? err.message : t("settings.providers.diagnostic.failedToFetch"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, provider, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
void fetchDiagnostic();
|
||||
} else {
|
||||
setDiagnostic(null);
|
||||
}
|
||||
}, [visible, fetchDiagnostic]);
|
||||
|
||||
const refreshButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
sheetStyles.iconButton,
|
||||
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
|
||||
loading ? sheetStyles.disabled : null,
|
||||
],
|
||||
[loading],
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
void fetchDiagnostic();
|
||||
}, [fetchDiagnostic]);
|
||||
|
||||
const copyButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
sheetStyles.iconButton,
|
||||
(Boolean(hovered) || pressed) && Boolean(diagnostic) && sheetStyles.iconButtonHovered,
|
||||
diagnostic ? null : sheetStyles.disabled,
|
||||
],
|
||||
[diagnostic],
|
||||
);
|
||||
|
||||
const handleCopyPress = useCallback(() => {
|
||||
if (!diagnostic) return;
|
||||
void Clipboard.setStringAsync(diagnostic)
|
||||
.then(() => toast.copied(t("settings.providers.diagnostic.copyLabel")))
|
||||
.catch(() => toast.error(t("settings.providers.diagnostic.copyFailed")));
|
||||
}, [diagnostic, t, toast]);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: t("settings.providers.diagnostic.title"),
|
||||
actions: (
|
||||
<View style={sheetStyles.headerActions}>
|
||||
<Pressable
|
||||
onPress={handleCopyPress}
|
||||
disabled={!diagnostic}
|
||||
hitSlop={8}
|
||||
style={copyButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.providers.diagnostic.copyAccessibility")}
|
||||
>
|
||||
<Copy size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleRefreshPress}
|
||||
disabled={loading}
|
||||
hitSlop={8}
|
||||
style={refreshButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
loading
|
||||
? t("settings.providers.diagnostic.refreshingAccessibility")
|
||||
: t("settings.providers.diagnostic.refreshAccessibility")
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
),
|
||||
}),
|
||||
[
|
||||
copyButtonStyle,
|
||||
diagnostic,
|
||||
handleCopyPress,
|
||||
handleRefreshPress,
|
||||
loading,
|
||||
refreshButtonStyle,
|
||||
t,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.sm,
|
||||
],
|
||||
);
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (loading && !diagnostic) {
|
||||
body = (
|
||||
<SurfaceCard key={visible ? "visible" : "hidden"}>
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
|
||||
</View>
|
||||
</SurfaceCard>
|
||||
);
|
||||
} else if (diagnostic) {
|
||||
body = (
|
||||
<ScrollableCodeSurface key={visible ? "visible" : "hidden"} maxHeight={480}>
|
||||
{diagnostic}
|
||||
</ScrollableCodeSurface>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<SurfaceCard key={visible ? "visible" : "hidden"}>
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
|
||||
</View>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={DIAGNOSTIC_SNAP_POINTS}
|
||||
scrollable={false}
|
||||
testID="provider-diagnostic-sheet"
|
||||
>
|
||||
{body}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderModalBodyProps {
|
||||
discoveredCount: number;
|
||||
additionalCount: number;
|
||||
providerSnapshotRefreshing: boolean;
|
||||
providerErrorMessage: string | null;
|
||||
modelsRefreshing: boolean;
|
||||
searchActive: boolean;
|
||||
filteredDiscovered: AgentModelDefinition[];
|
||||
filteredCustom: ProviderProfileModel[];
|
||||
deletingModelId: string | null;
|
||||
onRefresh: () => void;
|
||||
onDeleteCustom: (modelId: string) => void;
|
||||
theme: { iconSize: { md: number }; colors: { foregroundMuted: string } };
|
||||
}
|
||||
|
||||
interface ProviderSheetFooterInput {
|
||||
fetchedAt: string | null;
|
||||
isCompact: boolean;
|
||||
modelsRefreshing: boolean;
|
||||
t: TFunction;
|
||||
onOpenAddSheet: () => void;
|
||||
onOpenDiagSheet: () => void;
|
||||
onRefreshModels: () => void;
|
||||
}
|
||||
|
||||
function renderProviderSheetFooter({
|
||||
fetchedAt,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
onOpenAddSheet,
|
||||
onOpenDiagSheet,
|
||||
onRefreshModels,
|
||||
}: ProviderSheetFooterInput) {
|
||||
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
|
||||
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
|
||||
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
|
||||
return (
|
||||
<View style={contentStyle}>
|
||||
{fetchedAt || !isCompact ? (
|
||||
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
|
||||
) : null}
|
||||
<View style={actionsStyle}>
|
||||
<Button variant="secondary" leftIcon={Plus} onPress={onOpenAddSheet} style={buttonStyle}>
|
||||
{t("settings.providers.models.addModel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={FileText}
|
||||
onPress={onOpenDiagSheet}
|
||||
style={buttonStyle}
|
||||
>
|
||||
{t("settings.providers.diagnostic.button")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftIcon={modelsRefreshing ? undefined : RotateCw}
|
||||
onPress={onRefreshModels}
|
||||
disabled={modelsRefreshing}
|
||||
style={buttonStyle}
|
||||
>
|
||||
{modelsRefreshing
|
||||
? t("settings.providers.diagnostic.refreshing")
|
||||
: t("settings.providers.diagnostic.refresh")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderUpdatedLabel({
|
||||
fetchedAt,
|
||||
isCompact,
|
||||
}: {
|
||||
fetchedAt: string | null;
|
||||
isCompact: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = useTimeAgo(fetchedAt ? new Date(fetchedAt) : null);
|
||||
const style = isCompact
|
||||
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
|
||||
: sheetStyles.footerMeta;
|
||||
return (
|
||||
<Text style={style} numberOfLines={1}>
|
||||
{fetchedAt ? t("settings.providers.models.updated", { time: label }) : ""}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
discoveredCount,
|
||||
additionalCount,
|
||||
providerSnapshotRefreshing,
|
||||
providerErrorMessage,
|
||||
modelsRefreshing,
|
||||
searchActive,
|
||||
filteredDiscovered,
|
||||
filteredCustom,
|
||||
deletingModelId,
|
||||
onRefresh,
|
||||
onDeleteCustom,
|
||||
theme,
|
||||
} = props;
|
||||
|
||||
if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (discoveredCount === 0 && additionalCount === 0 && providerErrorMessage) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
|
||||
<Button variant="default" onPress={onRefresh} disabled={modelsRefreshing}>
|
||||
{modelsRefreshing
|
||||
? t("settings.providers.models.retrying")
|
||||
: t("settings.providers.models.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noSearchMatches")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (discoveredCount === 0 && additionalCount === 0) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noneDetected")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{filteredDiscovered.length > 0 ? (
|
||||
<View style={sheetStyles.section}>
|
||||
<SectionHeader
|
||||
title={t("settings.providers.models.discovered")}
|
||||
count={filteredDiscovered.length}
|
||||
/>
|
||||
<View style={settingsStyles.card}>
|
||||
{filteredDiscovered.map((model) => (
|
||||
<DiscoveredModelRow key={model.id} model={model} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
{filteredCustom.length > 0 ? (
|
||||
<View style={sheetStyles.section}>
|
||||
<SectionHeader
|
||||
title={t("settings.providers.models.custom")}
|
||||
count={filteredCustom.length}
|
||||
/>
|
||||
<View style={settingsStyles.card}>
|
||||
{filteredCustom.map((model) => (
|
||||
<CustomModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
deleting={deletingModelId === model.id}
|
||||
onDelete={onDeleteCustom}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderDiagnosticSheet({
|
||||
provider,
|
||||
visible,
|
||||
onClose,
|
||||
serverId,
|
||||
}: ProviderDiagnosticSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [query, setQuery] = useState("");
|
||||
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||
const [diagSheetOpen, setDiagSheetOpen] = useState(false);
|
||||
const [deletingModelId, setDeletingModelId] = useState<string | null>(null);
|
||||
|
||||
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
||||
const providerEntry = useMemo(
|
||||
() => snapshotEntries?.find((entry) => entry.provider === provider),
|
||||
[snapshotEntries, provider],
|
||||
);
|
||||
const additionalModels = useMemo(
|
||||
() => config?.providers?.[provider]?.additionalModels ?? [],
|
||||
[config?.providers, provider],
|
||||
);
|
||||
const providerSnapshotRefreshing = providerEntry?.status === "loading";
|
||||
const providerErrorMessage =
|
||||
providerEntry?.status === "error"
|
||||
? (providerEntry.error ?? t("settings.providers.diagnostic.unknownError"))
|
||||
: null;
|
||||
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
|
||||
|
||||
const stableDiscoveredRef = useRef<ProviderDiscoveredModelsCache | null>(null);
|
||||
const currentModels = providerEntry?.models;
|
||||
const { models: discoveredModels, cache: nextDiscoveredCache } = resolveProviderDiscoveredModels({
|
||||
serverId,
|
||||
provider,
|
||||
currentModels,
|
||||
providerSnapshotRefreshing,
|
||||
previousCache: stableDiscoveredRef.current,
|
||||
});
|
||||
stableDiscoveredRef.current = nextDiscoveredCache;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setQuery("");
|
||||
setAddSheetOpen(false);
|
||||
setDiagSheetOpen(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const q = query.trim();
|
||||
const filteredDiscovered = useMemo(
|
||||
() => rankModels(discoveredModels, q, (m) => [m.label, m.id, m.description ?? ""]),
|
||||
[discoveredModels, q],
|
||||
);
|
||||
const filteredCustom = useMemo(
|
||||
() => rankModels(additionalModels, q, (m) => [m.label, m.id]),
|
||||
[additionalModels, q],
|
||||
);
|
||||
|
||||
const handleRefreshModels = useCallback(() => {
|
||||
void refresh([provider]);
|
||||
}, [provider, refresh]);
|
||||
|
||||
const handleOpenAddSheet = useCallback(() => setAddSheetOpen(true), []);
|
||||
const handleCloseAddSheet = useCallback(() => setAddSheetOpen(false), []);
|
||||
const handleOpenDiagSheet = useCallback(() => setDiagSheetOpen(true), []);
|
||||
const handleCloseDiagSheet = useCallback(() => setDiagSheetOpen(false), []);
|
||||
|
||||
const handleDeleteCustom = useCallback(
|
||||
(modelId: string) => {
|
||||
setDeletingModelId(modelId);
|
||||
void patchConfig({
|
||||
providers: {
|
||||
[provider]: {
|
||||
additionalModels: additionalModels.filter((model) => model.id !== modelId),
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => refresh([provider]))
|
||||
.finally(() => {
|
||||
setDeletingModelId((current) => (current === modelId ? null : current));
|
||||
});
|
||||
},
|
||||
[additionalModels, patchConfig, provider, refresh],
|
||||
);
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: providerLabel,
|
||||
search: {
|
||||
onChange: setQuery,
|
||||
placeholder: t("settings.providers.models.searchPlaceholder"),
|
||||
testID: "provider-settings-search",
|
||||
},
|
||||
}),
|
||||
[providerLabel, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdaptiveModalSheet
|
||||
header={sheetHeader}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
testID="provider-settings-sheet"
|
||||
footer={renderProviderSheetFooter({
|
||||
fetchedAt: providerEntry?.fetchedAt ?? null,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
onOpenAddSheet: handleOpenAddSheet,
|
||||
onOpenDiagSheet: handleOpenDiagSheet,
|
||||
onRefreshModels: handleRefreshModels,
|
||||
})}
|
||||
snapPoints={MAIN_SNAP_POINTS}
|
||||
>
|
||||
<ProviderModalBody
|
||||
discoveredCount={discoveredModels.length}
|
||||
additionalCount={additionalModels.length}
|
||||
providerSnapshotRefreshing={providerSnapshotRefreshing}
|
||||
providerErrorMessage={providerErrorMessage}
|
||||
modelsRefreshing={modelsRefreshing}
|
||||
searchActive={Boolean(q)}
|
||||
filteredDiscovered={filteredDiscovered}
|
||||
filteredCustom={filteredCustom}
|
||||
deletingModelId={deletingModelId}
|
||||
onRefresh={handleRefreshModels}
|
||||
onDeleteCustom={handleDeleteCustom}
|
||||
theme={theme}
|
||||
/>
|
||||
</AdaptiveModalSheet>
|
||||
<AddCustomModelSubSheet
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
visible={addSheetOpen}
|
||||
onClose={handleCloseAddSheet}
|
||||
refresh={refresh}
|
||||
/>
|
||||
<DiagnosticSubSheet
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
visible={diagSheetOpen}
|
||||
onClose={handleCloseDiagSheet}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const sheetStyles = StyleSheet.create((theme) => ({
|
||||
mutedText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
monoHint: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
},
|
||||
descriptionInline: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
formInput: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foreground,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
iconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
section: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionHeaderMeta: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
modelRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
gap: theme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
modelTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
customModelText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
modelRowFiller: {
|
||||
flex: 1,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[8],
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
footerContent: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
compactFooterContent: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
footerMeta: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
compactFooterMeta: {
|
||||
flex: 0,
|
||||
},
|
||||
footerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
compactFooterActions: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
compactFooterButton: {
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
formGroup: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
formLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
formActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
codeBlockLoading: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
const MAIN_SNAP_POINTS = ["65%", "92%"];
|
||||
const ADD_SNAP_POINTS = ["40%"];
|
||||
const DIAGNOSTIC_SNAP_POINTS = ["50%", "85%"];
|
||||
@@ -0,0 +1,480 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import {
|
||||
ProviderInstallationSection,
|
||||
type ProviderInstallationState,
|
||||
type ProviderUpdateAction,
|
||||
type ProviderUpdatesState,
|
||||
} from "./provider-installation-section";
|
||||
|
||||
// Load translations so controls expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
const mounted: { root: Root; container: HTMLDivElement }[] = [];
|
||||
afterEach(() => {
|
||||
for (const entry of mounted.splice(0)) {
|
||||
act(() => entry.root.unmount());
|
||||
entry.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
const INSTALL_URL = "https://example.com/install";
|
||||
const UPDATE_URL = "https://example.com/update";
|
||||
|
||||
function mount(
|
||||
state: ProviderInstallationState,
|
||||
updates: ProviderUpdatesState = { status: "hidden" },
|
||||
action?: ProviderUpdateAction,
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() =>
|
||||
root.render(<ProviderInstallationSection state={state} updates={updates} action={action} />),
|
||||
);
|
||||
mounted.push({ root, container });
|
||||
const view = within(container);
|
||||
return {
|
||||
view,
|
||||
rerender: (nextUpdates: ProviderUpdatesState, nextAction?: ProviderUpdateAction) =>
|
||||
act(() =>
|
||||
root.render(
|
||||
<ProviderInstallationSection state={state} updates={nextUpdates} action={nextAction} />,
|
||||
),
|
||||
),
|
||||
installButton: () => view.queryByRole("button", { name: "Install" }),
|
||||
installLink: () => view.queryByRole("link", { name: "Install instructions" }),
|
||||
};
|
||||
}
|
||||
|
||||
function ready(installation: ProviderInstallationState & { status: "ready" }) {
|
||||
return installation;
|
||||
}
|
||||
|
||||
describe("install link visibility", () => {
|
||||
it("offers the Install button when the provider reports it is not installed", () => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: { installUrl: INSTALL_URL, inspection: { status: "not_installed" } },
|
||||
}),
|
||||
);
|
||||
expect(section.installButton()).not.toBeNull();
|
||||
expect(section.installLink()).toBeNull();
|
||||
});
|
||||
|
||||
it("offers a quiet install link when the installation check failed", async () => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
installUrl: INSTALL_URL,
|
||||
inspection: { status: "error", message: "version probe exploded" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect.element(section.view.getByText("version probe exploded")).toBeVisible();
|
||||
expect(section.installLink()).not.toBeNull();
|
||||
expect(section.installButton()).toBeNull();
|
||||
});
|
||||
|
||||
it("offers a quiet install link for a provider that only declares where to install it", () => {
|
||||
const section = mount(ready({ status: "ready", installation: { installUrl: INSTALL_URL } }));
|
||||
expect(section.installLink()).not.toBeNull();
|
||||
expect(section.installButton()).toBeNull();
|
||||
});
|
||||
|
||||
it("offers no install link once the provider reports it is installed", () => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
installUrl: INSTALL_URL,
|
||||
inspection: { status: "installed", version: "1" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(section.installLink()).toBeNull();
|
||||
expect(section.installButton()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("update check", () => {
|
||||
const installed = ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
installUrl: INSTALL_URL,
|
||||
updateUrl: UPDATE_URL,
|
||||
inspection: { status: "installed", version: "0.165.0" },
|
||||
},
|
||||
});
|
||||
|
||||
it("shows the reported versions and message as written, with Update instructions", async () => {
|
||||
const section = mount(installed, {
|
||||
status: "ready",
|
||||
updateCheck: {
|
||||
status: "available",
|
||||
versions: ["0.168.0", "nightly (unstable)"],
|
||||
message: "Restart to finish",
|
||||
},
|
||||
});
|
||||
await expect.element(section.view.getByText("Update available")).toBeVisible();
|
||||
expect(
|
||||
section.view.getAllByTestId("provider-updates-version").map((node) => node.textContent),
|
||||
).toEqual(["0.168.0", "nightly (unstable)"]);
|
||||
await expect.element(section.view.getByText("Restart to finish")).toBeVisible();
|
||||
await expect
|
||||
.element(section.view.getByRole("button", { name: "Update instructions" }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
const inconclusive = [
|
||||
{
|
||||
name: "unknown",
|
||||
updates: { status: "ready", updateCheck: { status: "unknown", message: "Offline" } },
|
||||
},
|
||||
{
|
||||
name: "a thrown or timed-out check",
|
||||
updates: { status: "ready", updateCheck: { status: "error", message: "Offline" } },
|
||||
},
|
||||
{ name: "a failed request", updates: { status: "error", message: "Offline" } },
|
||||
] as const;
|
||||
|
||||
it.each(inconclusive)("shows $name as inconclusive, never up to date", async ({ updates }) => {
|
||||
const section = mount(installed, updates);
|
||||
await expect.element(section.view.getByText("Couldn't check for updates")).toBeVisible();
|
||||
await expect.element(section.view.getByText("Offline")).toBeVisible();
|
||||
expect(section.view.queryByText("Up to date")).toBeNull();
|
||||
await expect
|
||||
.element(section.view.getByRole("button", { name: "Update instructions" }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it.each(inconclusive)(
|
||||
"still says $name when the provider offers no update",
|
||||
async ({ updates }) => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: { inspection: { status: "installed", version: "0.165.0" } },
|
||||
}),
|
||||
updates,
|
||||
);
|
||||
await expect.element(section.view.getByText("Couldn't check for updates")).toBeVisible();
|
||||
expect(section.view.queryByRole("button")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows up to date only when the provider says so", async () => {
|
||||
const section = mount(installed, { status: "ready", updateCheck: { status: "current" } });
|
||||
await expect.element(section.view.getByText("Up to date")).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows the check's answer for a provider that offers no update", async () => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: { inspection: { status: "installed", version: "1.4.18" } },
|
||||
}),
|
||||
{ status: "ready", updateCheck: { status: "current" } },
|
||||
);
|
||||
await expect.element(section.view.getByText("Up to date")).toBeVisible();
|
||||
expect(section.view.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "cannot check", updates: { status: "ready", updateCheck: null } },
|
||||
{ name: "is still checking", updates: { status: "loading" } },
|
||||
] as const)(
|
||||
"shows no Updates row while a provider that offers no update $name",
|
||||
({ updates }) => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
installUrl: INSTALL_URL,
|
||||
inspection: { status: "installed", version: "1.0.0" },
|
||||
},
|
||||
}),
|
||||
updates,
|
||||
);
|
||||
expect(section.view.queryByTestId("provider-updates")).toBeNull();
|
||||
expect(section.view.getByTestId("provider-installation-version").textContent).toBe("1.0.0");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("update action", () => {
|
||||
const installed = (canUpdate: boolean) =>
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
updateUrl: UPDATE_URL,
|
||||
inspection: { status: "installed", version: "1.0.0" },
|
||||
...(canUpdate ? { canUpdate: true } : {}),
|
||||
},
|
||||
});
|
||||
const available: ProviderUpdatesState = {
|
||||
status: "ready",
|
||||
updateCheck: { status: "available", versions: ["2.0.0"] },
|
||||
};
|
||||
function action(overrides: Partial<ProviderUpdateAction> = {}) {
|
||||
let starts = 0;
|
||||
const value: ProviderUpdateAction = {
|
||||
current: null,
|
||||
starting: false,
|
||||
startError: null,
|
||||
start: () => {
|
||||
starts += 1;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { value, starts: () => starts };
|
||||
}
|
||||
const operation = { id: "op", startedAt: "2026-11-13T00:00:00.000Z" };
|
||||
const finishedAt = "2026-11-13T00:01:00.000Z";
|
||||
|
||||
// The check only informs: Update and the update instructions never wait on it.
|
||||
const checkStates = [
|
||||
{ name: "an update is available", updates: available, says: "Update available" },
|
||||
// No check answer: the row shows only its title (asserted whole below).
|
||||
{
|
||||
name: "the provider cannot check",
|
||||
updates: { status: "ready", updateCheck: null },
|
||||
says: "Updates",
|
||||
},
|
||||
{ name: "the check is running", updates: { status: "loading" }, says: "Checking..." },
|
||||
{
|
||||
name: "the check could not tell",
|
||||
updates: { status: "ready", updateCheck: { status: "unknown" } },
|
||||
says: "Couldn't check for updates",
|
||||
},
|
||||
{
|
||||
name: "the check failed",
|
||||
updates: { status: "error", message: "Offline" },
|
||||
says: "Couldn't check for updates",
|
||||
},
|
||||
{
|
||||
name: "the provider is up to date",
|
||||
updates: { status: "ready", updateCheck: { status: "current" } },
|
||||
says: "Up to date",
|
||||
},
|
||||
] satisfies { name: string; updates: ProviderUpdatesState; says: string }[];
|
||||
|
||||
it.each(checkStates)(
|
||||
"offers Update when $name and the provider runs its own",
|
||||
async ({ updates, says }) => {
|
||||
const control = action();
|
||||
const section = mount(installed(true), updates, control.value);
|
||||
const button = section.view.getByRole("button", { name: "Update" });
|
||||
act(() => button.click());
|
||||
expect(control.starts()).toBe(1);
|
||||
expect(section.view.queryByRole("button", { name: "Update instructions" })).toBeNull();
|
||||
await expect.element(section.view.getByText(says, { exact: true })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(checkStates)(
|
||||
"offers Update instructions when $name and the provider only links its guide",
|
||||
async ({ updates }) => {
|
||||
const section = mount(installed(false), updates, action().value);
|
||||
expect(section.view.queryByRole("button", { name: "Update" })).toBeNull();
|
||||
await expect
|
||||
.element(section.view.getByRole("button", { name: "Update instructions" }))
|
||||
.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows no update status beside Update when the provider cannot check", () => {
|
||||
const section = mount(installed(true), { status: "ready", updateCheck: null }, action().value);
|
||||
expect(section.view.getByTestId("provider-updates").textContent).toBe("UpdatesUpdate");
|
||||
});
|
||||
|
||||
it("shows a running update with its output so far", () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
available,
|
||||
action({
|
||||
current: {
|
||||
operation: { ...operation, status: "running" },
|
||||
output: [
|
||||
{ type: "message", text: "Running codex update\n" },
|
||||
{ type: "stdout", text: "added 1 package\n" },
|
||||
],
|
||||
},
|
||||
}).value,
|
||||
);
|
||||
expect(section.view.queryByTestId("provider-update-status")).toBeNull();
|
||||
expect(section.view.getByTestId("provider-update-output").textContent).toBe(
|
||||
"Running codex update\nadded 1 package\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the provider's success message as written", () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
{ status: "ready", updateCheck: { status: "current" } },
|
||||
action({
|
||||
current: {
|
||||
operation: {
|
||||
...operation,
|
||||
status: "succeeded",
|
||||
finishedAt,
|
||||
message: " Restart agents \n",
|
||||
},
|
||||
output: [],
|
||||
},
|
||||
}).value,
|
||||
);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Updated");
|
||||
expect(section.view.getByTestId("provider-update-message").textContent).toBe(
|
||||
" Restart agents \n",
|
||||
);
|
||||
expect(section.view.queryByTestId("provider-update-output")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a failure with its message, its output and the update instructions", async () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
available,
|
||||
action({
|
||||
current: {
|
||||
operation: { ...operation, status: "failed", finishedAt, message: "exited with code 1" },
|
||||
output: [{ type: "stderr", text: "npm error EACCES\n" }],
|
||||
truncated: true,
|
||||
},
|
||||
}).value,
|
||||
);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
|
||||
await expect.element(section.view.getByText("exited with code 1")).toBeVisible();
|
||||
expect(section.view.getByTestId("provider-update-output").textContent).toBe(
|
||||
"npm error EACCES\n",
|
||||
);
|
||||
await expect.element(section.view.getByText("Earlier output was dropped.")).toBeVisible();
|
||||
await expect
|
||||
.element(section.view.getByRole("link", { name: "Update instructions" }))
|
||||
.toBeVisible();
|
||||
await expect.element(section.view.getByRole("button", { name: "Update" })).toBeVisible();
|
||||
});
|
||||
|
||||
// Update carries the update's progress from the tap until it settles, like every in-flight
|
||||
// button in Settings. The update's row shows only its output meanwhile.
|
||||
describe("progress", () => {
|
||||
const runningUpdate = (output: string[] = []) => ({
|
||||
operation: { ...operation, status: "running" as const },
|
||||
output: output.map((text) => ({ type: "stdout" as const, text })),
|
||||
});
|
||||
function progress(section: ReturnType<typeof mount>) {
|
||||
const indicators = section.view.queryAllByRole("progressbar");
|
||||
const button = section.view.queryByTestId("provider-update-start");
|
||||
return {
|
||||
count: indicators.length,
|
||||
inButton: indicators.every((indicator) => button?.contains(indicator) ?? false),
|
||||
label: button?.textContent,
|
||||
pressable: button?.getAttribute("aria-disabled") !== "true",
|
||||
};
|
||||
}
|
||||
const updating = { count: 1, inButton: true, label: "Updating...", pressable: false };
|
||||
const idle = { count: 0, inButton: true, label: "Update", pressable: true };
|
||||
|
||||
it("shows progress on Update alone from tap to settle", () => {
|
||||
const previous = {
|
||||
operation: { ...operation, status: "failed" as const, finishedAt, message: "exit 1" },
|
||||
output: [{ type: "stderr" as const, text: "old failure\n" }],
|
||||
};
|
||||
const section = mount(installed(true), available, action({ current: previous }).value);
|
||||
expect(progress(section)).toEqual(idle);
|
||||
|
||||
// Tapped, the host has not answered yet: the previous result gives way to the new update.
|
||||
section.rerender(available, action({ current: previous, starting: true }).value);
|
||||
expect(progress(section)).toEqual(updating);
|
||||
expect(section.view.queryByTestId("provider-update-run")).toBeNull();
|
||||
|
||||
// Running before any output arrives.
|
||||
section.rerender(available, action({ current: runningUpdate() }).value);
|
||||
expect(progress(section)).toEqual(updating);
|
||||
expect(section.view.queryByTestId("provider-update-run")).toBeNull();
|
||||
|
||||
section.rerender(available, action({ current: runningUpdate(["downloading\n"]) }).value);
|
||||
expect(progress(section)).toEqual(updating);
|
||||
expect(section.view.getByTestId("provider-update-run").textContent).toBe("downloading\n");
|
||||
|
||||
section.rerender(
|
||||
{ status: "ready", updateCheck: { status: "current" } },
|
||||
action({
|
||||
current: {
|
||||
...runningUpdate(["downloading\n"]),
|
||||
operation: { ...operation, status: "succeeded", finishedAt },
|
||||
},
|
||||
}).value,
|
||||
);
|
||||
expect(progress(section)).toEqual(idle);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Updated");
|
||||
});
|
||||
|
||||
it("shows progress on Update when the sheet reopens mid-update", () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
available,
|
||||
action({ current: runningUpdate(["downloading\n"]) }).value,
|
||||
);
|
||||
expect(progress(section)).toEqual(updating);
|
||||
expect(section.view.queryByTestId("provider-update-status")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "still loading", state: { status: "loading" } },
|
||||
{
|
||||
name: "not installed",
|
||||
state: { status: "ready", installation: { inspection: { status: "not_installed" } } },
|
||||
},
|
||||
{
|
||||
name: "failing",
|
||||
state: {
|
||||
status: "ready",
|
||||
installation: { inspection: { status: "error", message: "--version timed out" } },
|
||||
},
|
||||
},
|
||||
{ name: "unreachable", state: { status: "error", message: "Host disconnected" } },
|
||||
] satisfies { name: string; state: ProviderInstallationState }[])(
|
||||
"shows progress on Update while the installation reads as $name",
|
||||
({ state }) => {
|
||||
const section = mount(
|
||||
state,
|
||||
{ status: "hidden" },
|
||||
action({ current: runningUpdate() }).value,
|
||||
);
|
||||
// The inspection may show its own progress beside the Version row.
|
||||
const button = section.view.getByRole("button", { name: "Updating..." });
|
||||
expect(button.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(within(button).queryAllByRole("progressbar")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns Update to rest when the host refuses the start", () => {
|
||||
const section = mount(installed(true), available, action({ starting: true }).value);
|
||||
expect(progress(section)).toEqual(updating);
|
||||
section.rerender(available, action({ startError: "Busy" }).value);
|
||||
expect(progress(section)).toEqual(idle);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows why the host refused to start the update", async () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
available,
|
||||
action({ startError: "Provider codex cannot update itself" }).value,
|
||||
);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
|
||||
await expect
|
||||
.element(section.view.getByText("Provider codex cannot update itself"))
|
||||
.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type {
|
||||
ProviderInstallation,
|
||||
ProviderInstallationUpdate,
|
||||
ProviderInstallationUpdateCheck,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export type ProviderInstallationState =
|
||||
| { status: "unsupported" }
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; installation: ProviderInstallation | null }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
/**
|
||||
* What the provider's update check knows. `hidden` until the provider is installed. A null
|
||||
* `updateCheck` means the provider cannot check; the check only informs, and never gates Update.
|
||||
*/
|
||||
export type ProviderUpdatesState =
|
||||
| { status: "hidden" }
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; updateCheck: ProviderInstallationUpdateCheck | null }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
/** Running the provider's own update. Absent when the host cannot report updates. */
|
||||
export interface ProviderUpdateAction {
|
||||
/** The provider's latest update since the host started, or null. */
|
||||
current: ProviderInstallationUpdate | null;
|
||||
/** The request to start an update is in flight. */
|
||||
starting: boolean;
|
||||
/** Why the host did not start the update. */
|
||||
startError: string | null;
|
||||
start(): void;
|
||||
}
|
||||
|
||||
export function ProviderInstallationSection({
|
||||
state,
|
||||
updates,
|
||||
action,
|
||||
}: {
|
||||
state: ProviderInstallationState;
|
||||
updates: ProviderUpdatesState;
|
||||
action?: ProviderUpdateAction;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (state.status === "unsupported") return null;
|
||||
if (
|
||||
state.status === "ready" &&
|
||||
!state.installation?.inspection &&
|
||||
!state.installation?.installUrl
|
||||
)
|
||||
return null;
|
||||
const installation = state.status === "ready" ? state.installation : null;
|
||||
const run = action ? readUpdateRun(action) : null;
|
||||
// A running update keeps its row, so Update shows its progress whatever the inspection reads
|
||||
// meanwhile; the binary may be mid-replacement.
|
||||
const updating = action ? isUpdating(action) : false;
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section} testID="provider-installation">
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>
|
||||
{t("settings.providers.installation.title")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<InstallationRow state={state} />
|
||||
{installation?.inspection?.status === "installed" || updating ? (
|
||||
<UpdatesRow
|
||||
updates={updates}
|
||||
updateUrl={installation?.updateUrl}
|
||||
action={updating || installation?.canUpdate ? action : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{run ? <UpdateRunRow run={run} updateUrl={installation?.updateUrl} /> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallInstructionsLink({ installUrl }: { installUrl: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ExternalLink
|
||||
href={installUrl}
|
||||
label={t("settings.providers.installation.installInstructions")}
|
||||
testID="provider-installation-install-instructions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallationRow({ state }: { state: ProviderInstallationState }) {
|
||||
const { t } = useTranslation();
|
||||
const installation = state.status === "ready" ? state.installation : null;
|
||||
const installUrl = installation?.installUrl;
|
||||
const handleInstall = useCallback(() => {
|
||||
if (installUrl) void openExternalUrl(installUrl);
|
||||
}, [installUrl]);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.version")}</Text>
|
||||
<View style={styles.trailing}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.value}>{t("settings.providers.installation.checking")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const inspection = installation?.inspection;
|
||||
if (inspection?.status === "not_installed") {
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.providers.installation.notInstalled")}
|
||||
</Text>
|
||||
{installUrl ? (
|
||||
<Button variant="outline" onPress={handleInstall}>
|
||||
{t("settings.providers.installation.install")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (inspection?.status === "installed") {
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.version")}</Text>
|
||||
{inspection.version ? (
|
||||
<Text
|
||||
style={styles.version}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
testID="provider-installation-version"
|
||||
>
|
||||
{inspection.version}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={styles.value}>{t("settings.providers.installation.installed")}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// The installed state is unknown: the check failed, or the provider only links to a guide.
|
||||
let message: string | null = null;
|
||||
if (state.status === "error") message = state.message;
|
||||
else if (inspection?.status === "error") message = inspection.message;
|
||||
if (!message) {
|
||||
return installUrl ? (
|
||||
<View style={settingsStyles.row}>
|
||||
<InstallInstructionsLink installUrl={installUrl} />
|
||||
</View>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.failed")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{message}</Text>
|
||||
{installUrl ? (
|
||||
<View style={styles.link}>
|
||||
<InstallInstructionsLink installUrl={installUrl} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The update the provider offers, with what its check knows beside it. Update, or the update
|
||||
* instructions, stay in place whatever the check says: an answer can be an hour old, and the
|
||||
* provider's own updater reports when there is nothing newer.
|
||||
*/
|
||||
function UpdatesRow({
|
||||
updates,
|
||||
updateUrl,
|
||||
action,
|
||||
}: {
|
||||
updates: ProviderUpdatesState;
|
||||
updateUrl: string | undefined;
|
||||
/** Present when the provider runs its own update. */
|
||||
action: ProviderUpdateAction | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const updateCheck = updates.status === "ready" ? updates.updateCheck : null;
|
||||
const offersUpdate = Boolean(action || updateUrl);
|
||||
// With nothing to offer, the row waits until the check has something to say.
|
||||
if (!offersUpdate && !updateCheck && updates.status !== "error") return null;
|
||||
|
||||
return (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="provider-updates">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{updateCheck?.status === "available"
|
||||
? t("settings.providers.installation.updateAvailable")
|
||||
: t("settings.providers.installation.updates")}
|
||||
</Text>
|
||||
<UpdateCheckSummary updates={updates} />
|
||||
</View>
|
||||
<UpdateButton action={action} updateUrl={updateUrl} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateCheckSummary({ updates }: { updates: ProviderUpdatesState }) {
|
||||
const { t } = useTranslation();
|
||||
if (updates.status === "loading") {
|
||||
return (
|
||||
<View style={styles.trailing}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.providers.installation.checking")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (updates.status === "hidden") return null;
|
||||
const updateCheck = updates.status === "ready" ? updates.updateCheck : null;
|
||||
if (updates.status === "ready" && !updateCheck) return null;
|
||||
if (updateCheck?.status === "current") {
|
||||
return (
|
||||
<Text style={settingsStyles.rowHint} testID="provider-updates-current">
|
||||
{t("settings.providers.installation.upToDate")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (updateCheck?.status === "available") {
|
||||
// Versions are opaque; drop repeats so each one keys its own line.
|
||||
const versions = [...new Set(updateCheck.versions ?? [])];
|
||||
return (
|
||||
<>
|
||||
{versions.map((version) => (
|
||||
<Text
|
||||
key={version}
|
||||
style={styles.availableVersion}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
testID="provider-updates-version"
|
||||
>
|
||||
{version}
|
||||
</Text>
|
||||
))}
|
||||
{updateCheck.message ? (
|
||||
<Text style={settingsStyles.rowHint}>{updateCheck.message}</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
// A check that threw, timed out or could not tell is never shown as up to date.
|
||||
const message = updates.status === "error" ? updates.message : updateCheck?.message;
|
||||
return (
|
||||
<>
|
||||
<Text style={settingsStyles.rowHint} testID="provider-updates-unchecked">
|
||||
{t("settings.providers.installation.updatesFailed")}
|
||||
</Text>
|
||||
{message ? <Text style={settingsStyles.rowHint}>{message}</Text> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function isUpdating(action: ProviderUpdateAction): boolean {
|
||||
return action.starting || action.current?.operation.status === "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* Update runs the provider's updater; without one, the provider's update guide opens. The button
|
||||
* carries the update's progress from the tap until it settles.
|
||||
*/
|
||||
function UpdateButton({
|
||||
action,
|
||||
updateUrl,
|
||||
}: {
|
||||
action: ProviderUpdateAction | undefined;
|
||||
updateUrl: string | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleOpenInstructions = useCallback(() => {
|
||||
if (updateUrl) void openExternalUrl(updateUrl);
|
||||
}, [updateUrl]);
|
||||
if (action)
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={action.start}
|
||||
loading={isUpdating(action)}
|
||||
testID="provider-update-start"
|
||||
>
|
||||
{isUpdating(action)
|
||||
? t("settings.providers.installation.updating")
|
||||
: t("settings.providers.installation.update")}
|
||||
</Button>
|
||||
);
|
||||
if (!updateUrl) return null;
|
||||
return (
|
||||
<Button variant="outline" onPress={handleOpenInstructions}>
|
||||
{t("settings.providers.installation.updateInstructions")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type UpdateRun =
|
||||
| { status: "running"; update: ProviderInstallationUpdate }
|
||||
| { status: "succeeded" | "failed"; update: ProviderInstallationUpdate }
|
||||
| { status: "refused"; message: string };
|
||||
|
||||
/**
|
||||
* What the update's row shows, or null for nothing. While the update starts or runs, Update shows
|
||||
* its progress and the row holds only the output so far. A new start clears the previous result.
|
||||
*/
|
||||
function readUpdateRun(action: ProviderUpdateAction): UpdateRun | null {
|
||||
const update = action.current;
|
||||
if (isUpdating(action)) {
|
||||
return update?.operation.status === "running" && update.output.length > 0
|
||||
? { status: "running", update }
|
||||
: null;
|
||||
}
|
||||
if (action.startError) return { status: "refused", message: action.startError };
|
||||
if (!update || update.operation.status === "running") return null;
|
||||
return { status: update.operation.status, update };
|
||||
}
|
||||
|
||||
function UpdateRunRow({ run, updateUrl }: { run: UpdateRun; updateUrl: string | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const scroll = useRef<ScrollView>(null);
|
||||
const followOutput = useCallback(() => scroll.current?.scrollToEnd({ animated: false }), []);
|
||||
const update = run.status === "refused" ? null : run.update;
|
||||
const output = update?.output.map((entry) => entry.text).join("") ?? "";
|
||||
let message: string | undefined;
|
||||
if (run.status === "refused") message = run.message;
|
||||
else if (run.update.operation.status !== "running") message = run.update.operation.message;
|
||||
|
||||
return (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="provider-update-run">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
{run.status === "running" ? null : (
|
||||
<Text style={settingsStyles.rowTitle} testID="provider-update-status">
|
||||
{run.status === "succeeded"
|
||||
? t("settings.providers.installation.updated")
|
||||
: t("settings.providers.installation.updateFailed")}
|
||||
</Text>
|
||||
)}
|
||||
{message ? (
|
||||
<Text style={settingsStyles.rowHint} selectable testID="provider-update-message">
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
{output ? (
|
||||
<ScrollView
|
||||
ref={scroll}
|
||||
style={
|
||||
run.status === "running" ? styles.output : [styles.output, styles.outputAfterResult]
|
||||
}
|
||||
contentContainerStyle={styles.outputContent}
|
||||
onContentSizeChange={followOutput}
|
||||
// Android scrolls a ScrollView nested in the provider sheet only with this flag.
|
||||
nestedScrollEnabled
|
||||
>
|
||||
{update?.truncated ? (
|
||||
<Text style={styles.outputNote}>
|
||||
{t("settings.providers.installation.outputTruncated")}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={styles.outputText}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
testID="provider-update-output"
|
||||
>
|
||||
{output}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{(run.status === "failed" || run.status === "refused") && updateUrl ? (
|
||||
<View style={styles.link}>
|
||||
<ExternalLink
|
||||
href={updateUrl}
|
||||
label={t("settings.providers.installation.updateInstructions")}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
output: {
|
||||
maxHeight: 240,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
outputAfterResult: {
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
outputContent: {
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
outputText: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: 20,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
outputNote: {
|
||||
marginBottom: theme.spacing[1],
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
value: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
version: {
|
||||
flexShrink: 1,
|
||||
marginLeft: theme.spacing[3],
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
availableVersion: {
|
||||
marginTop: theme.spacing[1],
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
link: {
|
||||
flexDirection: "row",
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ProviderInstallationUpdate } from "@getpaseo/protocol/messages";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import type {
|
||||
ProviderInstallationState,
|
||||
ProviderUpdateAction,
|
||||
ProviderUpdatesState,
|
||||
} from "./provider-installation-section";
|
||||
import { applyProviderUpdateProgress } from "./provider-update-progress";
|
||||
|
||||
export interface ProviderInstallationRead {
|
||||
state: ProviderInstallationState;
|
||||
updates: ProviderUpdatesState;
|
||||
/** Absent when the host cannot report provider installations. */
|
||||
action: ProviderUpdateAction | undefined;
|
||||
/** Ask the provider again, skipping the host's caches. */
|
||||
refresh: () => void;
|
||||
/** A refresh is in flight. The last answers stay on screen until the new ones arrive. */
|
||||
refreshing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a provider's installation from hosts that advertise `providerInstallation`. The update
|
||||
* check is a separate request that starts once the provider reports itself installed, so the
|
||||
* installed version never waits on it.
|
||||
*/
|
||||
export function useProviderInstallation(input: {
|
||||
serverId: string;
|
||||
provider: string;
|
||||
visible: boolean;
|
||||
}): ProviderInstallationRead {
|
||||
const { serverId, provider, visible } = input;
|
||||
const supported = useHostFeature(serverId, "providerInstallation");
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const enabled = supported && visible && Boolean(client);
|
||||
const installationKey = ["provider-installation", serverId, provider] as const;
|
||||
const updatesKey = ["provider-updates", serverId, provider] as const;
|
||||
|
||||
const inspect = useCallback(
|
||||
async (refresh: boolean) => {
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const result = await client.inspectProviderInstallation(provider, { refresh });
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.installation;
|
||||
},
|
||||
[client, provider, t],
|
||||
);
|
||||
const checkForUpdates = useCallback(
|
||||
async (refresh: boolean) => {
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const result = await client.checkProviderUpdates(provider, { refresh });
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.updateCheck;
|
||||
},
|
||||
[client, provider, t],
|
||||
);
|
||||
|
||||
// The host caches both answers, so reading on every open is cheap.
|
||||
const installationQuery = useFetchQuery({
|
||||
queryKey: installationKey,
|
||||
queryFn: () => inspect(false),
|
||||
enabled,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
const installed = installationQuery.data?.inspection?.status === "installed";
|
||||
const updatesQuery = useFetchQuery({
|
||||
queryKey: updatesKey,
|
||||
queryFn: () => checkForUpdates(false),
|
||||
enabled: enabled && installed,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
|
||||
// A refresh runs through the queries so its answer or its failure is what the screen shows.
|
||||
// `staleTime: 0` overrides the app default so the request always goes out.
|
||||
const refreshInstallation = useMutation({
|
||||
mutationFn: () =>
|
||||
queryClient.fetchQuery({
|
||||
queryKey: installationKey,
|
||||
queryFn: () => inspect(true),
|
||||
staleTime: 0,
|
||||
}),
|
||||
});
|
||||
const refreshUpdates = useMutation({
|
||||
mutationFn: () =>
|
||||
queryClient.fetchQuery({
|
||||
queryKey: updatesKey,
|
||||
queryFn: () => checkForUpdates(true),
|
||||
staleTime: 0,
|
||||
}),
|
||||
});
|
||||
// The host owns the update; this view follows it while open and catches up on reopen.
|
||||
const [currentUpdate, setCurrentUpdate] = useState<ProviderInstallationUpdate | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled || !client) return;
|
||||
const subscription = client.observeProviderUpdates(provider);
|
||||
// The update this view saw running. Its success arrives as live progress, or as the snapshot
|
||||
// a reconnect delivers when the progress was missed; either way the host dropped its cached
|
||||
// answers, so read the new version and update state.
|
||||
let runningId: string | null = null;
|
||||
const follow = (operation: ProviderInstallationUpdate["operation"] | undefined) => {
|
||||
if (!operation) return;
|
||||
if (operation.status === "running") {
|
||||
runningId = operation.id;
|
||||
return;
|
||||
}
|
||||
if (operation.id !== runningId) return;
|
||||
runningId = null;
|
||||
if (operation.status !== "succeeded") return;
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["provider-installation", serverId, provider],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["provider-updates", serverId, provider] });
|
||||
};
|
||||
const detach = subscription.subscribe({
|
||||
snapshot: (response) => {
|
||||
setCurrentUpdate(response.update);
|
||||
follow(response.update?.operation);
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "provider.installation.update.progress") return;
|
||||
setCurrentUpdate((current) => applyProviderUpdateProgress(current, message.payload));
|
||||
follow(message.payload.operation);
|
||||
},
|
||||
error: (failure) => console.warn("Failed to observe provider updates", failure),
|
||||
});
|
||||
return () => {
|
||||
detach();
|
||||
void subscription
|
||||
.release()
|
||||
.catch((failure) => console.warn("Failed to release provider update observation", failure));
|
||||
};
|
||||
}, [client, enabled, provider, queryClient, serverId]);
|
||||
const startUpdate = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const result = await client.updateProvider(provider);
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.update;
|
||||
},
|
||||
onSuccess: (update) => {
|
||||
if (!update) return;
|
||||
// Progress for this operation may already have arrived; keep the further-along copy.
|
||||
setCurrentUpdate((current) =>
|
||||
current?.operation.id === update.operation.id ? current : update,
|
||||
);
|
||||
},
|
||||
});
|
||||
const { mutate: mutateStartUpdate } = startUpdate;
|
||||
const start = useCallback(() => mutateStartUpdate(), [mutateStartUpdate]);
|
||||
const action: ProviderUpdateAction | undefined = supported
|
||||
? {
|
||||
current: currentUpdate,
|
||||
starting: startUpdate.isPending,
|
||||
startError: startUpdate.error?.message ?? null,
|
||||
start,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const { mutate: mutateInstallation } = refreshInstallation;
|
||||
const { mutate: mutateUpdates } = refreshUpdates;
|
||||
const refresh = useCallback(() => {
|
||||
if (!supported || !client) return;
|
||||
mutateInstallation();
|
||||
if (installed) mutateUpdates();
|
||||
}, [client, installed, mutateInstallation, mutateUpdates, supported]);
|
||||
|
||||
let state: ProviderInstallationState;
|
||||
if (!supported) state = { status: "unsupported" };
|
||||
else if (installationQuery.isPending) state = { status: "loading" };
|
||||
else if (installationQuery.isError)
|
||||
state = { status: "error", message: installationQuery.error.message };
|
||||
else state = { status: "ready", installation: installationQuery.data ?? null };
|
||||
|
||||
let updates: ProviderUpdatesState;
|
||||
if (!supported || !installed) updates = { status: "hidden" };
|
||||
else if (updatesQuery.isPending) updates = { status: "loading" };
|
||||
else if (updatesQuery.isError) updates = { status: "error", message: updatesQuery.error.message };
|
||||
else updates = { status: "ready", updateCheck: updatesQuery.data ?? null };
|
||||
|
||||
const refreshing = refreshInstallation.isPending || refreshUpdates.isPending;
|
||||
return { state, updates, action, refresh, refreshing };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { ProviderDetailSheet } from "@/provider-settings";
|
||||
import { OverlayLayerProvider } from "@/lib/overlay-root";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ProviderSettingsHost() {
|
||||
|
||||
return (
|
||||
<OverlayLayerProvider layer={overlayParentLayer}>
|
||||
<ProviderDiagnosticSheet
|
||||
<ProviderDetailSheet
|
||||
key={`${serverId}:${provider}`}
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProviderInstallationUpdateProgress } from "@getpaseo/protocol/messages";
|
||||
import { applyProviderUpdateProgress } from "./provider-update-progress";
|
||||
|
||||
const running = {
|
||||
status: "running",
|
||||
id: "update-1",
|
||||
startedAt: "2026-11-13T00:00:00.000Z",
|
||||
} as const;
|
||||
|
||||
function progress(...texts: string[]): ProviderInstallationUpdateProgress {
|
||||
return {
|
||||
provider: "codex",
|
||||
operation: running,
|
||||
output: texts.map((text) => ({ type: "stdout", text })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyProviderUpdateProgress", () => {
|
||||
it("appends output for the same update", () => {
|
||||
const first = applyProviderUpdateProgress(null, progress("a"), { outputLimit: 10 });
|
||||
const second = applyProviderUpdateProgress(first, progress("b"), { outputLimit: 10 });
|
||||
expect(second.output.map((entry) => entry.text)).toEqual(["a", "b"]);
|
||||
expect(second.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops the oldest output beyond the limit and marks the update truncated", () => {
|
||||
let update = applyProviderUpdateProgress(null, progress("12345"), { outputLimit: 8 });
|
||||
update = applyProviderUpdateProgress(update, progress("6789"), { outputLimit: 8 });
|
||||
expect(update.output.map((entry) => entry.text)).toEqual(["6789"]);
|
||||
expect(update.truncated).toBe(true);
|
||||
|
||||
update = applyProviderUpdateProgress(update, progress("abcdefghij"), { outputLimit: 8 });
|
||||
expect(update.output.map((entry) => entry.text)).toEqual(["cdefghij"]);
|
||||
expect(update.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("starts a new update empty", () => {
|
||||
const first = applyProviderUpdateProgress(null, progress("123456789"), { outputLimit: 4 });
|
||||
const next = applyProviderUpdateProgress(
|
||||
first,
|
||||
{ ...progress("x"), operation: { ...running, id: "update-2" } },
|
||||
{ outputLimit: 4 },
|
||||
);
|
||||
expect(next.output.map((entry) => entry.text)).toEqual(["x"]);
|
||||
expect(next.truncated).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
ProviderInstallationUpdate,
|
||||
ProviderInstallationUpdateOutput,
|
||||
ProviderInstallationUpdateProgress,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
/** Characters of update output the app keeps, matching what the daemon retains. */
|
||||
export const PROVIDER_UPDATE_OUTPUT_LIMIT = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Folds a progress message into the update it belongs to; a new operation starts empty. Output
|
||||
* beyond `outputLimit` characters drops oldest first and marks the update truncated, so a noisy
|
||||
* updater cannot grow the screen's memory while it stays open.
|
||||
*/
|
||||
export function applyProviderUpdateProgress(
|
||||
current: ProviderInstallationUpdate | null,
|
||||
progress: ProviderInstallationUpdateProgress,
|
||||
options: { outputLimit: number } = { outputLimit: PROVIDER_UPDATE_OUTPUT_LIMIT },
|
||||
): ProviderInstallationUpdate {
|
||||
const previous = current?.operation.id === progress.operation.id ? current : null;
|
||||
const limit = options.outputLimit;
|
||||
const output: ProviderInstallationUpdateOutput[] = [
|
||||
...(previous?.output ?? []),
|
||||
...progress.output,
|
||||
];
|
||||
let length = output.reduce((total, entry) => total + entry.text.length, 0);
|
||||
let truncated = previous?.truncated ?? false;
|
||||
while (length > limit && output.length > 1) {
|
||||
length -= output.shift()!.text.length;
|
||||
truncated = true;
|
||||
}
|
||||
const last = output.at(-1);
|
||||
if (last && last.text.length > limit) {
|
||||
output[output.length - 1] = { type: last.type, text: last.text.slice(-limit) };
|
||||
truncated = true;
|
||||
}
|
||||
return { operation: progress.operation, output, ...(truncated ? { truncated: true } : {}) };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { useCallback } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import { useSettingsRowLayout } from "./row-density";
|
||||
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
|
||||
type HoverState = PressableStateCallbackType & { hovered?: boolean };
|
||||
|
||||
/**
|
||||
* The last row of a card that lists things, adding one more. Quiet, so the list reads first: a
|
||||
* muted plus in the rows' leading slot and a muted label on the labels' rail.
|
||||
*/
|
||||
export function SettingsAddRow({
|
||||
label,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
const layout = useSettingsRowLayout();
|
||||
const rowStyle = useCallback(
|
||||
({ hovered, pressed }: HoverState) => [
|
||||
layout.row,
|
||||
styles.row,
|
||||
hovered && styles.hovered,
|
||||
pressed && styles.pressed,
|
||||
],
|
||||
[layout],
|
||||
);
|
||||
return (
|
||||
<PressHighlight
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.pressed}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.leading}>
|
||||
<ThemedPlus size={ICON_SIZE.md} uniProps={mutedIconColorMapping} />
|
||||
</View>
|
||||
<Text style={[layout.title, styles.label]}>{label}</Text>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: { justifyContent: "flex-start" },
|
||||
leading: { marginRight: theme.spacing[3], alignItems: "center", justifyContent: "center" },
|
||||
label: { color: theme.colors.foregroundMuted },
|
||||
hovered: { backgroundColor: theme.colors.surface2 },
|
||||
pressed: { backgroundColor: theme.colors.surface3 },
|
||||
}));
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ArrowLeft } from "lucide-react-native";
|
||||
import type { ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* "Back to …" above a settings detail page on wide layouts, where the page has no header of its
|
||||
* own. The ghost button drops its padding so the arrow sits on the content's leading rail.
|
||||
*/
|
||||
export function SettingsBackLink({
|
||||
label,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
testID={testID}
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
variant="ghost"
|
||||
leftIcon={ArrowLeft}
|
||||
style={styles.backLink}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The top of a settings detail page: "Back to …" when the page has no header of its own, then the
|
||||
* icon and name the page is about, with an optional action after the name.
|
||||
*/
|
||||
export function SettingsDetailHeader({
|
||||
back,
|
||||
icon,
|
||||
title,
|
||||
trailing,
|
||||
titleTestID,
|
||||
}: {
|
||||
back?: { label: string; onPress: () => void; testID?: string };
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
trailing?: ReactNode;
|
||||
titleTestID?: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{back ? <SettingsBackLink {...back} /> : null}
|
||||
<View style={styles.titleRow}>
|
||||
{icon}
|
||||
<Text style={styles.title} numberOfLines={1} testID={titleTestID}>
|
||||
{title}
|
||||
</Text>
|
||||
{trailing}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
backLink: {
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
header: {
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
titleRow: {
|
||||
marginTop: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
@@ -1,12 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SettingsRowHeader } from "./row-header";
|
||||
import { SettingsRowHeader, type SettingsRowHintStatus } from "./row-header";
|
||||
|
||||
interface SettingsDetailRowProps {
|
||||
label: string;
|
||||
/** An icon before the label, such as the thing the row opens. */
|
||||
leading?: ReactNode;
|
||||
/** Shown beside the label, such as a badge. */
|
||||
labelAccessory?: ReactNode;
|
||||
/** A line under the label saying what the row leads to. */
|
||||
hint?: string;
|
||||
/** Marks the hint as work under way, a fault, or a value still loading. */
|
||||
hintStatus?: SettingsRowHintStatus;
|
||||
/** What the detail is set to, so the row says it without being opened. */
|
||||
summary?: string;
|
||||
onPress: () => void;
|
||||
@@ -14,6 +18,10 @@ interface SettingsDetailRowProps {
|
||||
disabled?: boolean;
|
||||
/** Opens a page outside the app; an outward arrow replaces the chevron. */
|
||||
external?: boolean;
|
||||
/** A control acting on the row's subject in place, such as a switch, before the chevron. */
|
||||
control?: ReactNode;
|
||||
/** Names the row for assistive tech when the label alone does not say where it leads. */
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppearanceText as Text } from "@/appearance/text";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ExternalLink, type ExternalLinkProps } from "@/components/ui/external-link";
|
||||
import { SettingsInfoTip } from "./settings-info-tip";
|
||||
|
||||
@@ -30,11 +30,16 @@ export { SettingsGroup } from "./headings/settings-group";
|
||||
export { SettingsSection } from "./headings/settings-section";
|
||||
export { SettingsCollapsibleRow } from "./collapsible-row";
|
||||
export { SettingsDetailRow } from "./detail-row";
|
||||
export type { SettingsRowHintStatus } from "./row-header";
|
||||
export { SettingsAddRow } from "./add-row";
|
||||
export { SettingsBackLink, SettingsDetailHeader } from "./detail-header";
|
||||
export { SettingsInfoTip } from "./headings/settings-info-tip";
|
||||
|
||||
/** What app screens can add to a plugin-facing row: an accessory beside the label, such as an info tip. */
|
||||
interface AppRowExtras {
|
||||
labelAccessory?: ReactNode;
|
||||
/** An icon before the label, such as the thing the row is about. */
|
||||
leading?: ReactNode;
|
||||
}
|
||||
|
||||
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint">, AppRowExtras {
|
||||
@@ -58,6 +63,7 @@ export function SettingsCard({ children, testID }: { children: ReactNode; testID
|
||||
|
||||
export function SettingsRow({
|
||||
label,
|
||||
leading,
|
||||
labelAccessory,
|
||||
hint,
|
||||
error,
|
||||
@@ -70,6 +76,7 @@ export function SettingsRow({
|
||||
const accessoryTitleStyle = useMemo(() => [layout.title, styles.accessoryLabel], [layout]);
|
||||
return (
|
||||
<View style={rowStyle} testID={testID}>
|
||||
{leading ? <View style={styles.leading}>{leading}</View> : null}
|
||||
<View style={styles.label}>
|
||||
{labelAccessory ? (
|
||||
<View style={styles.labelRow}>
|
||||
@@ -207,7 +214,11 @@ export function SettingsAction({ actionLabel, onPress, disabled, ...row }: Setti
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
compactRow: { flexWrap: "wrap", gap: theme.spacing[3] },
|
||||
// A control that wraps under the label gets space above it. Across the row, the leading slot and
|
||||
// the label already carry their own margins, so a column gap would double them.
|
||||
compactRow: { flexWrap: "wrap", rowGap: theme.spacing[3] },
|
||||
// The same leading slot as `SettingsRowHeader`, so icons and labels share rails across row kinds.
|
||||
leading: { marginRight: theme.spacing[3], alignItems: "center", justifyContent: "center" },
|
||||
label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] },
|
||||
labelRow: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -2,7 +2,12 @@ import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCollapsibleRow,
|
||||
SettingsDetailRow,
|
||||
SettingsRow,
|
||||
} from "@/components/settings";
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
@@ -14,6 +19,8 @@ afterEach(() => {
|
||||
container = null;
|
||||
});
|
||||
|
||||
function openNothing() {}
|
||||
|
||||
function mountRows() {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -26,6 +33,7 @@ function mountRows() {
|
||||
Download
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
<SettingsDetailRow label="Opens a page" onPress={openNothing} testID="detail-row" />
|
||||
<SettingsCollapsibleRow label="Group" defaultExpanded testID="group">
|
||||
<SettingsRow label="Nested with a button" testID="nested-with-button">
|
||||
<Button variant="outline" testID="nested-button">
|
||||
@@ -66,6 +74,13 @@ describe("settings row density", () => {
|
||||
expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button"));
|
||||
});
|
||||
|
||||
it("keeps a row that opens something as tall as a row holding a control", () => {
|
||||
mountRows();
|
||||
|
||||
expect(heightOf("detail-row")).toBe(heightOf("card-row"));
|
||||
expect(heightOf("group-toggle")).toBe(heightOf("card-row"));
|
||||
});
|
||||
|
||||
it("sizes nested text and controls one step below card rows", () => {
|
||||
mountRows();
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export function NestedSettingsRows({ children }: { children: ReactNode }) {
|
||||
|
||||
export interface SettingsRowLayout {
|
||||
row: StyleProp<ViewStyle>;
|
||||
/** A row as tall as one holding a control of `controlSize`, whether or not it holds one. */
|
||||
controlRow: StyleProp<ViewStyle>;
|
||||
title: StyleProp<TextStyle>;
|
||||
hint: StyleProp<TextStyle>;
|
||||
/** The size of the controls in the row's trailing slot. */
|
||||
@@ -30,12 +32,14 @@ export function useSettingsRowLayout(): SettingsRowLayout {
|
||||
nested
|
||||
? {
|
||||
row: [settingsStyles.row, styles.nestedRow],
|
||||
controlRow: [settingsStyles.row, styles.nestedRow],
|
||||
title: [settingsStyles.rowTitle, styles.nestedText],
|
||||
hint: [settingsStyles.rowHint, styles.nestedText],
|
||||
controlSize: "sm",
|
||||
}
|
||||
: {
|
||||
row: settingsStyles.row,
|
||||
controlRow: [settingsStyles.row, styles.cardControlRow],
|
||||
title: settingsStyles.rowTitle,
|
||||
hint: settingsStyles.rowHint,
|
||||
controlSize: "md",
|
||||
@@ -45,6 +49,9 @@ export function useSettingsRowLayout(): SettingsRowLayout {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
cardControlRow: {
|
||||
minHeight: mapLayout(CONTROL_HEIGHTS.md, (height) => height + theme.spacing[4] * 2),
|
||||
},
|
||||
// Every nested row is as tall as one holding an `sm` control, so rows with and without a
|
||||
// trailing button line up.
|
||||
nestedRow: {
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ControlSizeProvider } from "@/components/ui/control-size";
|
||||
import { ExternalLinkLabel } from "@/components/ui/external-link";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { useSettingsRowLayout } from "./row-density";
|
||||
import { useSettingsRowLayout, type SettingsRowLayout } from "./row-density";
|
||||
|
||||
const ThemedChevron = withUnistyles(ChevronRight);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
type HoverState = PressableStateCallbackType & { hovered?: boolean };
|
||||
/**
|
||||
* What the hint reports beyond its words: work under way (a leading spinner), a fault (a leading
|
||||
* red dot and red text), or a value still loading (a placeholder the height of the line).
|
||||
*/
|
||||
export type SettingsRowHintStatus = "busy" | "error" | "pending";
|
||||
|
||||
export interface SettingsRowHeaderProps {
|
||||
label: string;
|
||||
/** An icon before the label, such as the thing the row opens. */
|
||||
leading?: ReactNode;
|
||||
/** Shown beside the label, such as a badge. */
|
||||
labelAccessory?: ReactNode;
|
||||
/** A line under the label saying what the row leads to. */
|
||||
hint?: string;
|
||||
hintStatus?: SettingsRowHintStatus;
|
||||
/** A short status shown before the chevron. */
|
||||
summary?: string;
|
||||
onPress: () => void;
|
||||
@@ -28,6 +39,10 @@ export interface SettingsRowHeaderProps {
|
||||
expanded?: boolean;
|
||||
/** The row leaves the app, such as to a docs page: the summary and an outward arrow are drawn as an external link in place of the chevron. */
|
||||
external?: boolean;
|
||||
/** A control acting on the row's subject in place, such as a switch, before the chevron. */
|
||||
control?: ReactNode;
|
||||
/** Names the row for assistive tech when the label alone does not say where it leads. */
|
||||
accessibilityLabel?: string;
|
||||
accessibilityState?: AccessibilityState;
|
||||
/** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */
|
||||
webAccessibilityProps?: Record<string, unknown> | null;
|
||||
@@ -38,85 +53,216 @@ export interface SettingsRowHeaderProps {
|
||||
* The pressable line shared by settings rows that lead somewhere: a label, an optional summary and
|
||||
* a trailing chevron. A collapsible row turns the chevron down; a row that opens a sheet keeps it
|
||||
* pointing right.
|
||||
*
|
||||
* The press target is a button laid under the row's content rather than wrapped around it, so a
|
||||
* control in the row is the button's sibling: a switch inside a button is invalid markup, and keys
|
||||
* pressed on the switch would also press the row. Everything drawn over the button lets presses
|
||||
* through to it. The label, icon and summary stay out of the accessibility tree, where the button's
|
||||
* label already names the row; the hint stays readable, since it carries the row's state.
|
||||
* Hover is tracked on the row itself, so it holds while the pointer is over the control.
|
||||
*
|
||||
* The row is as tall as one holding a control whether or not it holds one, so a list where only
|
||||
* some rows carry a switch keeps one row height.
|
||||
*/
|
||||
export function SettingsRowHeader({
|
||||
label,
|
||||
leading,
|
||||
labelAccessory,
|
||||
hint,
|
||||
hintStatus,
|
||||
summary,
|
||||
onPress,
|
||||
disabled,
|
||||
expanded = false,
|
||||
external = false,
|
||||
control,
|
||||
accessibilityLabel,
|
||||
accessibilityState,
|
||||
webAccessibilityProps,
|
||||
testID,
|
||||
}: SettingsRowHeaderProps) {
|
||||
const layout = useSettingsRowLayout();
|
||||
const rowStyle = useCallback(
|
||||
({ hovered, pressed }: HoverState) => [
|
||||
layout.row,
|
||||
!disabled && hovered && styles.hovered,
|
||||
const [pointerOver, setPointerOver] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setPointerOver(true), []);
|
||||
const handlePointerLeave = useCallback(() => setPointerOver(false), []);
|
||||
const hovered = pointerOver && !disabled;
|
||||
const rowStyle = useMemo(() => [layout.controlRow, hovered && styles.hovered], [hovered, layout]);
|
||||
const pressTargetStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
styles.pressTarget,
|
||||
!disabled && pressed && styles.pressed,
|
||||
],
|
||||
[disabled, layout],
|
||||
[disabled],
|
||||
);
|
||||
const chevronStyle = useMemo(
|
||||
() => [styles.chevron, expanded && styles.chevronExpanded],
|
||||
[expanded],
|
||||
);
|
||||
return (
|
||||
<PressHighlight
|
||||
{...webAccessibilityProps}
|
||||
accessibilityRole={external ? "link" : "button"}
|
||||
accessibilityLabel={summary ? `${label}, ${summary}` : label}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
<View
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.pressed}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered }: HoverState) => (
|
||||
<>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
{labelAccessory ? (
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
{labelAccessory}
|
||||
<PressHighlight
|
||||
{...webAccessibilityProps}
|
||||
accessibilityRole={external ? "link" : "button"}
|
||||
accessibilityLabel={accessibilityLabel ?? (summary ? `${label}, ${summary}` : label)}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={pressTargetStyle}
|
||||
highlightStyle={styles.pressed}
|
||||
/>
|
||||
{leading ? (
|
||||
<View style={[styles.leading, styles.passThrough]} {...HIDDEN_FROM_ASSISTIVE_TECH}>
|
||||
{leading}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[settingsStyles.rowContent, styles.passThrough]}>
|
||||
<View style={labelAccessory ? styles.labelRow : undefined} {...HIDDEN_FROM_ASSISTIVE_TECH}>
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
{labelAccessory}
|
||||
</View>
|
||||
{hintStatus ? (
|
||||
<RowHintLine hint={hint} status={hintStatus} hintStyle={layout.hint} />
|
||||
) : null}
|
||||
{hint && !hintStatus ? <Text style={layout.hint}>{hint}</Text> : null}
|
||||
</View>
|
||||
{control ? (
|
||||
<View style={styles.control}>
|
||||
<ControlSizeProvider size={layout.controlSize}>{control}</ControlSizeProvider>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[styles.trailing, styles.passThrough]} {...HIDDEN_FROM_ASSISTIVE_TECH}>
|
||||
{external ? (
|
||||
// Drawn the same as a section header's docs link, so the two read as one thing.
|
||||
<ExternalLinkLabel hovered={hovered}>{summary}</ExternalLinkLabel>
|
||||
) : (
|
||||
<>
|
||||
{summary ? (
|
||||
<Text style={styles.summary} numberOfLines={1}>
|
||||
{summary}
|
||||
</Text>
|
||||
) : null}
|
||||
{disabled ? null : (
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevron
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
)}
|
||||
{hint ? <Text style={layout.hint}>{hint}</Text> : null}
|
||||
</View>
|
||||
{external ? (
|
||||
// Drawn the same as a section header's docs link, so the two read as one thing.
|
||||
<ExternalLinkLabel hovered={hovered}>{summary}</ExternalLinkLabel>
|
||||
) : (
|
||||
<>
|
||||
{summary ? (
|
||||
<Text style={styles.summary} numberOfLines={1}>
|
||||
{summary}
|
||||
</Text>
|
||||
) : null}
|
||||
{disabled ? null : (
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevron
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PressHighlight>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const HIDDEN_FROM_ASSISTIVE_TECH = {
|
||||
accessibilityElementsHidden: true,
|
||||
importantForAccessibility: "no-hide-descendants",
|
||||
"aria-hidden": true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The hint with its status mark. The line keeps the hint's own height in every status, so a row
|
||||
* does not move when its value arrives or its work starts. A long error is cut to the line; the
|
||||
* row's detail says it in full.
|
||||
*/
|
||||
function RowHintLine({
|
||||
hint,
|
||||
status,
|
||||
hintStyle,
|
||||
}: {
|
||||
hint: string | undefined;
|
||||
status: SettingsRowHintStatus;
|
||||
hintStyle: SettingsRowLayout["hint"];
|
||||
}) {
|
||||
const textStyle = useMemo(
|
||||
() => [hintStyle, styles.hintText, status === "error" && styles.hintError],
|
||||
[hintStyle, status],
|
||||
);
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<View style={styles.hintLine}>
|
||||
{/* A blank line of hint text holds the height; the placeholder sits over it. */}
|
||||
<Text style={textStyle}> </Text>
|
||||
<HintPlaceholder />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.hintLine}>
|
||||
{status === "busy" ? (
|
||||
<ThemedLoadingSpinner size={14} uniProps={mutedIconColorMapping} />
|
||||
) : (
|
||||
<View style={styles.errorDot} />
|
||||
)}
|
||||
<Text style={textStyle} numberOfLines={1}>
|
||||
{hint}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HintPlaceholder() {
|
||||
const pulse = useSkeletonPulse();
|
||||
return <SkeletonPulse pulse={pulse} style={styles.hintPlaceholder} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
leading: {
|
||||
marginRight: theme.spacing[3],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
control: {
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
// The button under the row's content; it fills the row, padding included.
|
||||
pressTarget: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
passThrough: {
|
||||
pointerEvents: "none",
|
||||
},
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexShrink: 1,
|
||||
},
|
||||
hintLine: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
// The line carries the hint's top margin, so the mark and the words share one baseline box.
|
||||
hintText: {
|
||||
marginTop: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
hintError: {
|
||||
color: theme.colors.statusDanger,
|
||||
},
|
||||
errorDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
hintPlaceholder: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: theme.spacing[0.5],
|
||||
bottom: theme.spacing[0.5],
|
||||
width: theme.spacing[32],
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
labelRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
summary: {
|
||||
flexShrink: 1,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
@@ -674,7 +674,11 @@ function WorkspaceRowRightGroup({
|
||||
{renderSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
|
||||
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
<SidebarWorkspaceTrailingContent
|
||||
workspace={workspace}
|
||||
trailing={trailing}
|
||||
backdrop={backdrop}
|
||||
/>
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay
|
||||
visible={kebab.showKebab}
|
||||
@@ -2588,7 +2592,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
|
||||
// Schedules icon across the divider; their layout boxes have different insets.
|
||||
paddingTop: 2,
|
||||
paddingBottom: theme.spacing[4],
|
||||
// Keep the last row scrollable above the sidebar toast, including a wrapped Undo row.
|
||||
// This space stays present across toast changes so the scroll range never jumps.
|
||||
paddingBottom: theme.spacing[24],
|
||||
},
|
||||
projectListContainer: {
|
||||
width: "100%",
|
||||
|
||||
@@ -58,6 +58,8 @@ import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
|
||||
import { useWorkspaceLabelProjection } from "@/workspace-labels";
|
||||
import { WorkspaceLabelDot } from "@/workspace-labels/swatch";
|
||||
import { WorkspaceLabelManagerModal } from "@/workspace-labels/manager-modal";
|
||||
import { PluginIconView } from "@/plugins/icons";
|
||||
import type { PluginWorkspaceRowBadgeItemGroup } from "@/plugins/workspace-row-badge-items";
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -65,6 +67,7 @@ const ThemedSettings2 = withUnistyles(Settings2);
|
||||
/** CI's mark: the subject of the checks row, and the shape the icon-only option leaves behind. */
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircle = withUnistyles(Circle);
|
||||
const ThemedPluginIconView = withUnistyles(PluginIconView);
|
||||
|
||||
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
|
||||
const OPTION_ICON_SIZE = 14;
|
||||
@@ -607,9 +610,9 @@ function OptionList<Value extends string>({
|
||||
}
|
||||
|
||||
/**
|
||||
* Two groups, split by the separator. Above it, what a row may say about a workspace — each one
|
||||
* independent. Below it, the one thing the slot to the right of the title holds, so picking the
|
||||
* one already showing empties the slot and gives the width back to the title.
|
||||
* Three groups: Paseo's own row items, plugin badge items, then the one thing the slot to the
|
||||
* right of the title holds. Picking the trailing choice already showing empties the slot and
|
||||
* gives the width back to the title.
|
||||
*
|
||||
* CI is the one item above the separator with three answers rather than two, so it opens a page
|
||||
* instead of ticking, and it goes last: a row that navigates does not belong in the middle of a
|
||||
@@ -632,6 +635,12 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
/>
|
||||
))}
|
||||
<ChecksSubTrigger />
|
||||
{preferences.pluginRowBadgeItems.length > 0 ? (
|
||||
<MenuSeparator testID="sidebar-plugin-row-badge-items-separator" />
|
||||
) : null}
|
||||
{preferences.pluginRowBadgeItems.map((item) => (
|
||||
<PluginRowBadgeItemOption key={item.key} item={item} preferences={preferences} />
|
||||
))}
|
||||
<MenuSeparator />
|
||||
{TRAILING_CHOICES.map((choice) => (
|
||||
<OptionItem
|
||||
@@ -649,6 +658,37 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** A plugin's workspace row badge item, toggled like the row's own. */
|
||||
function PluginRowBadgeItemOption({
|
||||
item,
|
||||
preferences,
|
||||
}: {
|
||||
item: PluginWorkspaceRowBadgeItemGroup;
|
||||
preferences: Preferences;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(
|
||||
() => preferences.togglePluginRowBadgeItem(item.key),
|
||||
[item.key, preferences],
|
||||
);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<ThemedPluginIconView icon={item.icon} size={OPTION_ICON_SIZE} uniProps={mutedIconMapping} />
|
||||
),
|
||||
[item.icon],
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
selected={preferences.isPluginRowBadgeItemVisible(item.key)}
|
||||
leading={leading}
|
||||
closeOnSelect={false}
|
||||
onSelect={handleSelect}
|
||||
testID={`sidebar-row-badge-item-plugin-${item.pluginId}-${item.itemId}`}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* No value on the row, only the chevron. The three answers are all long enough that the value
|
||||
* fills the row and stops reading as an answer sitting at the right edge — it turns into a second
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
type SidebarGroupMode,
|
||||
type SidebarLabelFilter,
|
||||
} from "@/stores/sidebar-view-store";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import {
|
||||
groupPluginWorkspaceRowBadgeItems,
|
||||
isWorkspaceRowBadgeItemVisible,
|
||||
type PluginWorkspaceRowBadgeItemGroup,
|
||||
} from "@/plugins/workspace-row-badge-items";
|
||||
import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay } from "./checks-display";
|
||||
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
|
||||
|
||||
@@ -27,6 +33,10 @@ export interface SidebarDisplayPreferences {
|
||||
setTitleSource: (source: WorkspaceTitleSource) => void;
|
||||
rowItems: SidebarRowItems;
|
||||
toggleRowItem: (item: SidebarRowItem) => void;
|
||||
/** Workspace row badge items contributed by installed plugins, one per plugin item across hosts. */
|
||||
pluginRowBadgeItems: readonly PluginWorkspaceRowBadgeItemGroup[];
|
||||
isPluginRowBadgeItemVisible: (key: string) => boolean;
|
||||
togglePluginRowBadgeItem: (key: string) => void;
|
||||
checksDisplay: SidebarChecksDisplay;
|
||||
setChecksDisplay: (display: SidebarChecksDisplay) => void;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
@@ -75,9 +85,30 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
sidebarWorkspaceTrailing,
|
||||
sidebarRowItems,
|
||||
sidebarChecksDisplay,
|
||||
sidebarPluginRowBadgeItems,
|
||||
},
|
||||
updateSettings,
|
||||
} = useAppSettings();
|
||||
const installedPlugins = useInstalledPlugins();
|
||||
const pluginRowBadgeItems = useMemo(
|
||||
() => groupPluginWorkspaceRowBadgeItems(installedPlugins),
|
||||
[installedPlugins],
|
||||
);
|
||||
const isPluginRowBadgeItemVisible = useCallback(
|
||||
(key: string) => isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
|
||||
[sidebarPluginRowBadgeItems],
|
||||
);
|
||||
const togglePluginRowBadgeItem = useCallback(
|
||||
(key: string) => {
|
||||
void updateSettings({
|
||||
sidebarPluginRowBadgeItems: {
|
||||
...sidebarPluginRowBadgeItems,
|
||||
[key]: !isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateSettings, sidebarPluginRowBadgeItems],
|
||||
);
|
||||
|
||||
const setTitleSource = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
@@ -123,6 +154,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
setTitleSource,
|
||||
rowItems: sidebarRowItems,
|
||||
toggleRowItem,
|
||||
pluginRowBadgeItems,
|
||||
isPluginRowBadgeItemVisible,
|
||||
togglePluginRowBadgeItem,
|
||||
checksDisplay: sidebarChecksDisplay,
|
||||
setChecksDisplay,
|
||||
trailing: sidebarWorkspaceTrailing,
|
||||
@@ -148,6 +182,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
setTitleSource,
|
||||
sidebarRowItems,
|
||||
toggleRowItem,
|
||||
pluginRowBadgeItems,
|
||||
isPluginRowBadgeItemVisible,
|
||||
togglePluginRowBadgeItem,
|
||||
sidebarChecksDisplay,
|
||||
setChecksDisplay,
|
||||
sidebarWorkspaceTrailing,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
|
||||
import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user