mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e635d9b46 | ||
|
|
3e859625ed | ||
|
|
83b1a3cbe2 | ||
|
|
0132f9f3e3 |
@@ -406,7 +406,10 @@
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/app/_layout.tsx",
|
||||
"packages/app/src/app/pair-scan.tsx",
|
||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||
"packages/app/src/components/add-host-method-modal.tsx",
|
||||
"packages/app/src/components/add-host-modal.tsx",
|
||||
"packages/app/src/components/agent-list.tsx",
|
||||
"packages/app/src/components/agent-status-dot.tsx",
|
||||
"packages/app/src/components/attachment-lightbox.tsx",
|
||||
@@ -425,6 +428,7 @@
|
||||
"packages/app/src/components/hosts/host-picker.tsx",
|
||||
"packages/app/src/components/icons/paseo-logo.tsx",
|
||||
"packages/app/src/components/left-sidebar.tsx",
|
||||
"packages/app/src/components/pair-link-modal.tsx",
|
||||
"packages/app/src/components/plan-card.tsx",
|
||||
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/question-form-card.tsx",
|
||||
@@ -539,10 +543,14 @@
|
||||
"packages/app/src/agent-skills/selection-sheet.tsx",
|
||||
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
|
||||
"packages/app/src/agent-stream/view.tsx",
|
||||
"packages/app/src/app/pair-scan.tsx",
|
||||
"packages/app/src/changelog/internal/changelog-sheet.tsx",
|
||||
"packages/app/src/command-center/command-center.tsx",
|
||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||
"packages/app/src/components/add-host-method-modal.tsx",
|
||||
"packages/app/src/components/add-host-modal.tsx",
|
||||
"packages/app/src/components/add-project-flow.tsx",
|
||||
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
|
||||
"packages/app/src/components/agent-list.tsx",
|
||||
"packages/app/src/components/app-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/attachment-lightbox.tsx",
|
||||
|
||||
+6
-72
@@ -1,81 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.0-beta.1 - 2026-11-01
|
||||
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
|
||||
|
||||
Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations.
|
||||
Added:
|
||||
|
||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
||||
|
||||
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||
Improved:
|
||||
|
||||
### Added
|
||||
|
||||
- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371))
|
||||
- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger))
|
||||
- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena))
|
||||
- Automatically resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
|
||||
- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348))
|
||||
- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
|
||||
- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso))
|
||||
- Added daemon-wide support for `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
|
||||
- Fetch usage only while displayed, with a host setting to disable usage checks entirely ([#8181](https://github.com/getpaseo/paseo/pull/8181))
|
||||
|
||||
### Improved
|
||||
|
||||
- Replaced mobile terminal text rendering to align CJK characters, emoji, styled text and the cursor with terminal columns ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
|
||||
- Restore the configured terminal scrollback on reconnect, up to 10,000 retained lines ([#8282](https://github.com/getpaseo/paseo/pull/8282) by [@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
|
||||
- Load paginated Codex histories when importing or reopening sessions ([#8171](https://github.com/getpaseo/paseo/pull/8171) by [@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
|
||||
- Stopped rereading and retaining an unused copy of Codex history when reopening a cached chat ([#8132](https://github.com/getpaseo/paseo/pull/8132))
|
||||
- Split large daemon responses across relay frames instead of disconnecting at the frame limit ([#8302](https://github.com/getpaseo/paseo/pull/8302))
|
||||
- Truncate oversized chat entries before sending them to the app, including large tool results and embedded data ([#8274](https://github.com/getpaseo/paseo/pull/8274))
|
||||
- Show subagent activity in its own panel instead of duplicating it inside the parent’s tool card ([#8276](https://github.com/getpaseo/paseo/pull/8276))
|
||||
- Show exact usage reset times alongside more precise countdowns ([#8162](https://github.com/getpaseo/paseo/pull/8162))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Fixed discarded messages returning after rewinding plugin conversations ([#8368](https://github.com/getpaseo/paseo/pull/8368) by [@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
|
||||
- Reduced plugin downloads with smaller client bundles and a cache reused across reconnects and app launches ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||
- Preserved plugin screens and local state during temporary host disconnections ([#8105](https://github.com/getpaseo/paseo/pull/8105) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||
- Let plugins choose worktree locations and creation options through `worktree.create.before` ([#8117](https://github.com/getpaseo/paseo/pull/8117) by [@mcowger](https://github.com/mcowger))
|
||||
- Added `server.addHook` with an awaited `worktree.create.after` hook before workspace setup and agents start ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
- Released redundant plugin streaming snapshots instead of retaining them for the session’s lifetime ([#7675](https://github.com/getpaseo/paseo/pull/7675) by [@suntianc](https://github.com/suntianc))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed rewinding Claude conversations to messages sent during an active turn ([#8372](https://github.com/getpaseo/paseo/pull/8372) by [@tieo](https://github.com/tieo))
|
||||
- Fixed duplicate rows and missing replies when resuming Claude subagents after reopening a session ([#8408](https://github.com/getpaseo/paseo/pull/8408) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||
- Fixed Codex agents created without a prompt failing after their runtime reopens ([#8393](https://github.com/getpaseo/paseo/pull/8393) by [@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||
- Fixed newly running agents appearing as finished immediately after creation ([#8396](https://github.com/getpaseo/paseo/pull/8396) by [@luoph](https://github.com/luoph))
|
||||
- Fixed ACP startup exits waiting for a timeout instead of reporting the process error ([#8384](https://github.com/getpaseo/paseo/pull/8384) by [@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||
- Fixed skills missing from new ACP agents when commands arrive in multiple batches ([#8387](https://github.com/getpaseo/paseo/pull/8387) by [@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||
- Fixed sidebar titles and statuses stopping updates after directory subscriptions are released ([#8403](https://github.com/getpaseo/paseo/pull/8403) by [@ragokan](https://github.com/ragokan))
|
||||
- Kept the open workspace visible in collapsed sidebar groups ([#8395](https://github.com/getpaseo/paseo/pull/8395) by [@0xroylee](https://github.com/0xroylee))
|
||||
- Fixed relative chat links opening missing files instead of files in nested repositories ([#8361](https://github.com/getpaseo/paseo/pull/8361) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Accepted host:port and pasted URLs in the Direct connection Host field ([#8223](https://github.com/getpaseo/paseo/pull/8223) by [@DaydreamCoding](https://github.com/DaydreamCoding))
|
||||
- Fixed terminal Ctrl+V paste over plain HTTP on non-Mac keyboards ([#8230](https://github.com/getpaseo/paseo/pull/8230) by [@DaydreamCoding](https://github.com/DaydreamCoding))
|
||||
- Stopped failed history loads from repeatedly reopening the provider, with Retry available in the chat ([#8278](https://github.com/getpaseo/paseo/pull/8278))
|
||||
- Fixed Claude settings files being discarded when Paseo adds its own launch settings ([#8148](https://github.com/getpaseo/paseo/pull/8148) by [@yayugu](https://github.com/yayugu))
|
||||
- Fixed steering messages replacing a turn that was still starting and losing its original prompt ([#6192](https://github.com/getpaseo/paseo/pull/6192))
|
||||
- Fixed Pi tool-result images missing during streaming and after reopening the chat ([#8280](https://github.com/getpaseo/paseo/pull/8280) by [@duyiliu](https://github.com/duyiliu))
|
||||
- Fixed completed `edxeth/pi-subagents` children remaining marked as working ([#8176](https://github.com/getpaseo/paseo/pull/8176) by [@Zeno2019](https://github.com/Zeno2019))
|
||||
- Fixed Claude background subagent status after reopening a conversation ([#6259](https://github.com/getpaseo/paseo/pull/6259))
|
||||
- Fixed reused plugin subagents showing their previous completed status during new work ([#8080](https://github.com/getpaseo/paseo/pull/8080) by [@bccy](https://github.com/bccy))
|
||||
- Fixed custom Codex providers with reserved names such as `ollama` ([#6954](https://github.com/getpaseo/paseo/pull/6954) by [@mr860819](https://github.com/mr860819))
|
||||
- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||
- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285))
|
||||
- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
|
||||
- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer))
|
||||
- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
|
||||
- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||
- Fixed split dividers drifting ahead of or behind the pointer while dragging ([#6011](https://github.com/getpaseo/paseo/pull/6011))
|
||||
- Fixed delayed native filesystem watcher startup leaving Git updates unobserved ([#8107](https://github.com/getpaseo/paseo/pull/8107))
|
||||
- Fixed bundled skill arguments being passed incorrectly to Codex and Pi ([#8252](https://github.com/getpaseo/paseo/pull/8252) by [@AspireOne](https://github.com/AspireOne))
|
||||
|
||||
### Notes
|
||||
|
||||
- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them
|
||||
- Automatic agent recovery is on by default and sends `Resume working.`; change the prompt or disable it in Host settings → Agents
|
||||
- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings
|
||||
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
|
||||
## 0.14.0 - 2026-10-29
|
||||
|
||||
|
||||
+7
-73
@@ -1,81 +1,15 @@
|
||||
# Changelog
|
||||
# 更新日志
|
||||
|
||||
## 0.15.0-beta.1 - 2026-11-01
|
||||
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
|
||||
|
||||
daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。
|
||||
新增:
|
||||
|
||||
移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
|
||||
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
||||
|
||||
worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用插件指定的位置。
|
||||
改进:
|
||||
|
||||
### 新增
|
||||
|
||||
- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371))
|
||||
- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger))
|
||||
- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena))
|
||||
- 主机重启后,自动在原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
|
||||
- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348))
|
||||
- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
|
||||
- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso))
|
||||
- 让 daemon 的网络请求支持 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
|
||||
- 仅在显示用量时查询用量,并提供主机设置以完全关闭用量查询 ([#8181](https://github.com/getpaseo/paseo/pull/8181))
|
||||
|
||||
### 改进
|
||||
|
||||
- 更换移动端终端文本渲染方式,让中日韩字符、表情符号、带样式文本和光标对齐终端列 ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
|
||||
- 重新连接时恢复设置指定的终端回滚历史,最多保留 10,000 行 ([#8282](https://github.com/getpaseo/paseo/pull/8282),贡献者:[@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
|
||||
- 导入或重新打开 Codex 会话时加载分页历史 ([#8171](https://github.com/getpaseo/paseo/pull/8171),贡献者:[@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
|
||||
- 重新打开已有缓存的 Codex 聊天时,不再重复读取并保留未使用的历史副本 ([#8132](https://github.com/getpaseo/paseo/pull/8132))
|
||||
- 将 daemon 的大型响应拆分为多个 relay 帧,避免达到单帧限制时断开连接 ([#8302](https://github.com/getpaseo/paseo/pull/8302))
|
||||
- 向应用发送聊天条目前截断超大内容,包括大型工具结果和内嵌数据 ([#8274](https://github.com/getpaseo/paseo/pull/8274))
|
||||
- 在子 Agent 自己的面板中显示活动,不再在父 Agent 的工具卡片中重复显示 ([#8276](https://github.com/getpaseo/paseo/pull/8276))
|
||||
- 在更精确的倒计时旁显示用量限制的具体重置时间 ([#8162](https://github.com/getpaseo/paseo/pull/8162))
|
||||
|
||||
### 插件
|
||||
|
||||
- 修复了插件会话回退后已丢弃的消息重新出现的问题 ([#8368](https://github.com/getpaseo/paseo/pull/8368),贡献者:[@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
|
||||
- 通过缩小客户端代码包并在重连和应用启动时复用缓存,减少插件下载量 ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||
- 在主机暂时断开连接期间保留插件页面和本地状态 ([#8105](https://github.com/getpaseo/paseo/pull/8105),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||
- 让插件通过 `worktree.create.before` 指定 worktree 的位置和创建选项 ([#8117](https://github.com/getpaseo/paseo/pull/8117),贡献者:[@mcowger](https://github.com/mcowger))
|
||||
- 新增 `server.addHook`,并在工作区初始化和 Agent 启动前等待 `worktree.create.after` hook 完成 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
- 释放多余的插件流式快照,不再将其保留到会话结束 ([#7675](https://github.com/getpaseo/paseo/pull/7675),贡献者:[@suntianc](https://github.com/suntianc))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复了无法将 Claude 会话回退到运行过程中发送的消息的问题 ([#8372](https://github.com/getpaseo/paseo/pull/8372),贡献者:[@tieo](https://github.com/tieo))
|
||||
- 修复了重新打开会话后恢复 Claude 子 Agent 时出现重复行和回复缺失的问题 ([#8408](https://github.com/getpaseo/paseo/pull/8408),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||
- 修复了未发送提示词的 Codex Agent 在运行时重新打开后无法使用的问题 ([#8393](https://github.com/getpaseo/paseo/pull/8393),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||
- 修复了刚开始运行的 Agent 在创建后立即显示为已完成的问题 ([#8396](https://github.com/getpaseo/paseo/pull/8396),贡献者:[@luoph](https://github.com/luoph))
|
||||
- 修复了 ACP 启动进程退出后仍等待超时而未及时报告错误的问题 ([#8384](https://github.com/getpaseo/paseo/pull/8384),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||
- 修复了命令分批到达时新 ACP Agent 的技能列表不完整的问题 ([#8387](https://github.com/getpaseo/paseo/pull/8387),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||
- 修复了目录订阅释放后侧栏标题和状态停止更新的问题 ([#8403](https://github.com/getpaseo/paseo/pull/8403),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 在折叠的侧栏分组中保持当前工作区可见 ([#8395](https://github.com/getpaseo/paseo/pull/8395),贡献者:[@0xroylee](https://github.com/0xroylee))
|
||||
- 修复了聊天中的相对链接未打开嵌套仓库中的文件而指向不存在文件的问题 ([#8361](https://github.com/getpaseo/paseo/pull/8361),贡献者:[@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- 在直接连接的主机字段中支持 host:port 和粘贴的 URL ([#8223](https://github.com/getpaseo/paseo/pull/8223),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
|
||||
- 修复了非 Mac 键盘通过普通 HTTP 访问网页终端时 Ctrl+V 无法粘贴的问题 ([#8230](https://github.com/getpaseo/paseo/pull/8230),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
|
||||
- 停止在历史加载失败后反复重新打开 Provider,可在聊天中手动重试 ([#8278](https://github.com/getpaseo/paseo/pull/8278))
|
||||
- 修复了 Paseo 添加自身启动设置时丢弃 Claude 设置文件的问题 ([#8148](https://github.com/getpaseo/paseo/pull/8148),贡献者:[@yayugu](https://github.com/yayugu))
|
||||
- 修复了引导消息替换尚在启动的轮次并丢失原始提示词的问题 ([#6192](https://github.com/getpaseo/paseo/pull/6192))
|
||||
- 修复了 Pi 工具结果中的图片在流式输出和重新打开聊天后缺失的问题 ([#8280](https://github.com/getpaseo/paseo/pull/8280),贡献者:[@duyiliu](https://github.com/duyiliu))
|
||||
- 修复了 `edxeth/pi-subagents` 子 Agent 完成后仍显示为工作中的问题 ([#8176](https://github.com/getpaseo/paseo/pull/8176),贡献者:[@Zeno2019](https://github.com/Zeno2019))
|
||||
- 修复了重新打开会话后的 Claude 后台子 Agent 状态 ([#6259](https://github.com/getpaseo/paseo/pull/6259))
|
||||
- 修复了复用的插件子 Agent 开始新任务时仍显示上次已完成状态的问题 ([#8080](https://github.com/getpaseo/paseo/pull/8080),贡献者:[@bccy](https://github.com/bccy))
|
||||
- 修复了使用 `ollama` 等保留名称的自定义 Codex Provider ([#6954](https://github.com/getpaseo/paseo/pull/6954),贡献者:[@mr860819](https://github.com/mr860819))
|
||||
- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||
- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285))
|
||||
- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
|
||||
- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer))
|
||||
- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
|
||||
- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||
- 修复了拖动分隔线时,分隔线偏离指针的问题 ([#6011](https://github.com/getpaseo/paseo/pull/6011))
|
||||
- 修复了原生文件监听器启动延迟导致 Git 更新未被监听的问题 ([#8107](https://github.com/getpaseo/paseo/pull/8107))
|
||||
- 修复了内置 skill 参数未正确传递给 Codex 和 Pi 的问题 ([#8252](https://github.com/getpaseo/paseo/pull/8252),贡献者:[@AspireOne](https://github.com/AspireOne))
|
||||
|
||||
### 说明
|
||||
|
||||
- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端
|
||||
- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能
|
||||
- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置
|
||||
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
|
||||
## 0.14.0 - 2026-10-29
|
||||
|
||||
|
||||
@@ -61,18 +61,6 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
|
||||
|
||||
## Local build + install
|
||||
|
||||
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
|
||||
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
|
||||
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
|
||||
regression renders real gradient pixels across growing and shrinking layouts, including a nested
|
||||
group. From `packages/app/android`, run it on your selected emulator:
|
||||
|
||||
```bash
|
||||
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
|
||||
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
|
||||
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
|
||||
It backports
|
||||
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
|
||||
|
||||
@@ -74,10 +74,7 @@ registrations remain inspectable; a changed destination requires explicit identi
|
||||
|
||||
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
||||
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
||||
operations; requesting this feature gives update guidance. The registry retries an older destination
|
||||
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
|
||||
authentication failures stay in `error` until an update changes the connection, `enabled`, or
|
||||
`replaceIdentity`, or A restarts.
|
||||
operations; requesting this feature gives update guidance.
|
||||
|
||||
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
||||
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
||||
|
||||
@@ -65,7 +65,6 @@ per-agent examples and provider-specific keys.
|
||||
## Table of Contents
|
||||
|
||||
- [Extending a built-in provider](#extending-a-built-in-provider)
|
||||
- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server)
|
||||
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
||||
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
||||
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
|
||||
@@ -108,35 +107,6 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai
|
||||
|
||||
---
|
||||
|
||||
## OpenCode with an existing v2 server
|
||||
|
||||
Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"opencode": {
|
||||
"env": {
|
||||
"PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096",
|
||||
"PASEO_OPENCODE_SERVER_PASSWORD": "<server password>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`.
|
||||
|
||||
A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server.
|
||||
|
||||
An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package.
|
||||
|
||||
OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service.
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
|
||||
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
|
||||
|
||||
+4
-4
@@ -35,7 +35,7 @@ The authored interface ramp uses a 14px base. New native installs default to 15p
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
|
||||
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx:19-23`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
||||
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
||||
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
|
||||
|
||||
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.
|
||||
@@ -102,7 +102,7 @@ Five primitives. The pick is determined by option count, the need to search, and
|
||||
|
||||
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`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`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
||||
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
||||
|
||||
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
|
||||
|
||||
@@ -114,7 +114,7 @@ Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value
|
||||
|
||||
## 7. Density and rhythm
|
||||
|
||||
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`).
|
||||
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
|
||||
|
||||
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
|
||||
|
||||
@@ -278,7 +278,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
| 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` |
|
||||
| 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` |
|
||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/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` |
|
||||
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
|
||||
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
|
||||
|
||||
@@ -58,12 +58,6 @@ restore the same workspace and could repeat the crash.
|
||||
|
||||
## App-Wide Route Hops
|
||||
|
||||
Keep Add host in `hosts/add-host`, opened through `useAddHost()`. It is an in-place
|
||||
flow; callers choose what happens after success. Settings opts into the new host’s
|
||||
Overview. Do not send callers to Settings with an `addHost` query parameter: a
|
||||
retained route replays that request when appearance changes remount its content.
|
||||
The flow provider and sheet host stay outside appearance keys so open drafts survive.
|
||||
|
||||
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
|
||||
branch on its current route.
|
||||
|
||||
+3
-21
@@ -357,27 +357,9 @@ keys are stored under a `param.` prefix; plugins never see it and may use any ke
|
||||
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
|
||||
params.
|
||||
|
||||
RPC and subscription contracts validate inputs, outputs, and events in both the app and plugin
|
||||
subprocess. `useRpc` returns a typed async function. Use the host-provided `@tanstack/react-query`
|
||||
for request state and caching; Paseo gives each plugin installation its own query client.
|
||||
|
||||
A subscription is a session-owned observation (`plugin-rpc` delivery family): the session that
|
||||
subscribed owns its lifetime, so release, disconnect, plugin stop, and reload need no plugin code.
|
||||
Each subscriber gets its own handler call in the subprocess; there is no fan-out or replay. The
|
||||
runtime buffers pushes until the session has sent `plugin.rpc.subscribe.response`, so a client
|
||||
never sees an event for an unknown subscription. The app installation's lifetime aborts every
|
||||
subscription its surfaces left open, and a reload replaces the installation, which resubscribes
|
||||
mounted hooks.
|
||||
|
||||
The byte rule (a top-level `Uint8Array` is binary, a nested one is a validation error) is checked
|
||||
once per hop by `assertPluginWireValue`. Bytes ride plugin binary frames, opcodes `0x20`-`0x2f`
|
||||
(`packages/protocol/src/binary-frames/plugin.ts`); nothing on the path copies or re-encodes them,
|
||||
and `plugin-subscriptions.perf.e2e.test.ts` locks that. Keep it that way: never stringify a plugin
|
||||
payload for a log, and the client's bootstrap buffer holds bytes only until the first observer
|
||||
attaches. Binary outputs reach only clients that send `pluginBinaryPayloads`. A binary input
|
||||
frame (`0x22`) becomes a synthetic `plugin.rpc.invoke.request` and runs through the normal message
|
||||
path, so it is authorized like any other invoke. The author contract, including the native upload
|
||||
limit, is in [stream events from the daemon](../public-docs/plugins/reference.md#subscriptions).
|
||||
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
|
||||
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
||||
Paseo gives each plugin installation its own query client.
|
||||
|
||||
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
|
||||
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
|
||||
|
||||
+1
-3
@@ -89,7 +89,7 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
||||
|
||||
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.
|
||||
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects versions older than 2.0.10. Paseo selects an explicit server, then a healthy local registered service, then its own helper. It never stops a reused service. A local service loads the Paseo bridge from its watched plugin directory; sessions bind their own bridge credentials through OpenCode RPC. See [OpenCode with an existing v2 server](custom-providers.md#opencode-with-an-existing-v2-server) for setup and isolation limits. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
|
||||
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
|
||||
|
||||
@@ -507,8 +507,6 @@ interface AgentSession {
|
||||
}
|
||||
```
|
||||
|
||||
`describePersistence()` returns a handle only once a new provider process could resume it. Reload, idle runtime collection and daemon restart resume whatever handle the agent stored; a stored agent without one starts a new session instead. When there is no handle, `AgentManager` stores `getRuntimeInfo().sessionId` as one, so that ID follows the same rule. Codex, for example, starts a thread when the agent is created but writes its rollout only at its first turn, and `thread/resume` fails for it until then, so its adapter reports neither ID before Codex announces that turn.
|
||||
|
||||
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
|
||||
|
||||
### Steps
|
||||
|
||||
@@ -6,25 +6,15 @@ Web and Electron draw terminals with xterm.js. iOS and Android keep an `@xterm/h
|
||||
|
||||
xterm decides which column every cell occupies. The native views never let text advance move a cell. Each row is shaped once, one run per font face, so Hangul jamo, ligatures, combining marks, conjuncts and fallback fonts see their neighbours. Each shaped cluster is then drawn at its first cell's column, centred in the columns it owns, and scaled down when its glyphs are wider. Cursor, selection, hit-testing and copy all use xterm's columns, so they stay correct whatever the font does.
|
||||
|
||||
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. xterm usually keeps a conjunct in one cell (see [Character widths](#character-widths)), but a colour change inside it leaves `क्` and `ष` in separate cells that still shape together. Drawing part of a cluster on its own reshapes it: the conjunct comes apart into `क्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
|
||||
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. Drawing part of a cluster on its own reshapes it: a half-form conjunct such as `स्ते` comes apart into `स्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
|
||||
|
||||
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some clusters that span cells. Grouping only merges whole cells, so a conjunct xterm keeps in one cell cannot be split by the OS. Grapheme rules follow the OS ICU version, and we accept that to avoid bundling approximately 14 MB of ICU4J data. Before the buffers used grapheme widths, Android 10 (API 29) split `स्ते` across its two cells. This renderer does not guarantee identical shaping across OS versions.
|
||||
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some conjuncts. Grapheme rules follow the OS ICU version. Android 10 (API 29) splits `स्ते` after the virama and renders it incorrectly. The tested corpus passed on API 33, 34 and 35; API 30–32 have not been tested. We accept that version-dependent limitation to avoid bundling approximately 14 MB of ICU4J data. CoreText keeps this conjunct together on iOS. This renderer does not guarantee identical shaping across OS versions.
|
||||
|
||||
`TerminalRowLayoutTest.kt` is the Android regression test. It draws the rows of `grapheme-rows.json` and compares each with its grapheme clusters drawn one at a time, and with a wrong clustering that must not match. `terminal-frame.test.ts` checks that those rows are what xterm produces and that the clusters are UAX #29 grapheme clusters. Run it with `./gradlew :paseo-terminal:connectedDebugAndroidTest` from `packages/app/android`, with `ANDROID_SERIAL` set.
|
||||
|
||||
The view reports ready only after it has measured its grid size. The pane attaches with that size, so the daemon replays the terminal at the client's size. Before this, the replay arrived at the PTY's old size, the resize came after it, and the shell's redraw landed on the wrong rows.
|
||||
|
||||
## Character widths
|
||||
|
||||
The daemon, native and web buffers all load `@xterm/addon-unicode-graphemes`: the daemon through `packages/server/src/terminal/headless-terminal.ts`, native in `createNativeHeadlessTerminal`, web in `terminal-emulator-runtime.ts`. Each emoji cluster takes one two-column cell, including VS16 (`❤️`), skin tones (`👋🏽`), flags (`🇰🇷`) and ZWJ sequences (`👨👩👧`). That is the width colour-emoji fonts draw at. Under xterm's default Unicode 6 table `😀` got one column, and the native views scaled it to half size to fit.
|
||||
|
||||
Every buffer must use the same table. The daemon answers cursor-position reports (`CSI 6n`) from its buffer, and a client re-parses the daemon's ANSI replay with its own table. A disagreement moves the cursor and the later cells of any row that holds a disputed cluster: after replay from a Unicode 6 daemon, the cursor that followed `A😀😀😀B` lands inside the third emoji.
|
||||
|
||||
Daemons that use grapheme widths advertise `server_info.features["terminal-grapheme-widths"]`. `terminal-pane.tsx` reads it once and passes `characterWidths` to the emulator. Against an older daemon the app keeps each renderer's previous table: xterm's default on native, Unicode 11 on web. An older app against a newer daemon cannot match it, because every client shares the daemon's one buffer. A terminal kept by a worker from an unreleased pre-grapheme build keeps that worker's table until it exits.
|
||||
|
||||
The table follows Unicode grapheme clusters, as Node's `string-width` does for Ink-based agent TUIs. Indic conjuncts follow the same rule: `क्ष` and `स्ते` take one column, and the native views scale them down to fit it. Shells measure with the C library's `wcwidth` instead. glibc counts `❤️` as one column, `👋🏽` as four and `क्ष` as two, so a line editor that measures those disagrees with the terminal.
|
||||
|
||||
Use `@xterm/addon-unicode-graphemes` 0.5.0-beta.330 or later. Earlier versions read their width table from the wrong offset when Node decodes it into a pooled `Buffer`, which gives wrong widths or a load error depending on earlier allocations. `headless-terminal.test.ts` forces that case in a fresh process.
|
||||
Emoji widths come from the headless buffer, which uses xterm's default Unicode 6 table. `👍` gets one column, and `👋🏽` and `🇰🇷` get two cells drawn as one cluster. Web loads the Unicode 11 addon, so the same output can wrap differently on web and native.
|
||||
|
||||
## Ink outside the row
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ buildNpmPackage {
|
||||
# The Nix output is not a distributable DMG, so leave it unsigned and
|
||||
# disable the hardened runtime that requires a matching signature.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
||||
npm exec --offline -- electron-builder \
|
||||
../../node_modules/.bin/electron-builder \
|
||||
--config electron-builder.yml \
|
||||
--dir \
|
||||
--mac \
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
|
||||
sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw=
|
||||
|
||||
Generated
+1368
-1092
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from "node:path";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import {
|
||||
addProjectFlow,
|
||||
chooseAddHostFromProjectFlow,
|
||||
addProjectFlowBack,
|
||||
addProjectFlowHost,
|
||||
addProjectFlowInput,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
} from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
disconnectAllHostsAndReload,
|
||||
addConnectedHostAndReload,
|
||||
addConnectedHostsAndReload,
|
||||
addOfflineHostAndReload,
|
||||
@@ -71,19 +69,6 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
||||
test.describe("Add Project command-center flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("Add host replaces the project dialog when no hosts are connected", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await disconnectAllHostsAndReload(page);
|
||||
await openAddProjectHostSelection(page);
|
||||
const originalUrl = page.url();
|
||||
await chooseAddHostFromProjectFlow(page);
|
||||
await expect(addProjectFlow(page)).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Direct connection", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
});
|
||||
|
||||
test("method selection shows the daemon's available project sources without search", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -68,54 +67,3 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
|
||||
await page.getByRole("link", { name: linkName, exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
async function returnToNestedLinks(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "nested-file-link-",
|
||||
title: "Nested file links",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse:
|
||||
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
|
||||
mockStreamingAssistantIntervalMs: 5,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
|
||||
await writeFile(
|
||||
path.join(agent.cwd, "repo-a/schema/x.md"),
|
||||
"# Nested file content\n\nDetails\n",
|
||||
);
|
||||
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
|
||||
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
|
||||
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
|
||||
await readLinkedGuide(page, "nested guide", "Nested file content");
|
||||
await returnToNestedLinks(page);
|
||||
await readLinkedGuide(page, "exact guide", "Exact file content");
|
||||
await returnToNestedLinks(page);
|
||||
await page.getByRole("link", { name: "missing guide", exact: true }).click();
|
||||
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
attachFileFromMenu,
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
} from "../support/helpers/composer";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
@@ -369,56 +365,6 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("inserts suggested commands from assistant messages without sending", async ({ page }) => {
|
||||
const requests = recordCommandRequestsByAgentId(page);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "suggested-command-",
|
||||
title: "Suggested commands",
|
||||
initialPrompt: "Suggest `/mock` without running it",
|
||||
featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" });
|
||||
await expect(message).toBeVisible();
|
||||
await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0);
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
await attachFileFromMenu(page, {
|
||||
name: "note.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("attachment"),
|
||||
});
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
||||
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
||||
await expect(composerLocator(page)).toHaveValue("/mock ");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await message.getByRole("link", { name: "Run mock" }).click();
|
||||
await expect(composerLocator(page)).toHaveValue("/mock /mock ");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
||||
await composerLocator(page).fill("draft");
|
||||
await composerLocator(page).evaluate((element: HTMLTextAreaElement) =>
|
||||
element.setSelectionRange(0, 0),
|
||||
);
|
||||
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
||||
await composerLocator(page).pressSequentially("argument");
|
||||
await expect(composerLocator(page)).toHaveValue("draft /mock argument");
|
||||
await composerLocator(page).fill("/");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toContainText("/mock");
|
||||
expect(requests).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -12,14 +12,8 @@ function userMessages(page: Page) {
|
||||
return page.getByTestId("user-message");
|
||||
}
|
||||
|
||||
async function sendDraftWhenReady(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await pressInComposer(page, "Enter");
|
||||
}
|
||||
|
||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||
await fillComposerDraft(page, text);
|
||||
await sendDraftWhenReady(page);
|
||||
await submitMessage(page, text);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
@@ -55,7 +49,7 @@ test.describe("Composer recall", () => {
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await sendDraftWhenReady(page);
|
||||
await pressInComposer(page, "Enter");
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
discardDictation,
|
||||
expectEmptyDictation,
|
||||
installSyntheticMicrophone,
|
||||
observeDictation,
|
||||
recordDictation,
|
||||
retryDictation,
|
||||
} from "../support/helpers/dictation";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// Silence is finalized by the real daemon without calling a transcription service.
|
||||
// A closed local endpoint makes an unexpected transcription request fail the test.
|
||||
test.use({
|
||||
e2eDaemonEnvironment: {
|
||||
PASEO_DICTATION_ENABLED: "1",
|
||||
PASEO_DICTATION_SILENCE_PEAK_THRESHOLD: "300",
|
||||
OPENAI_API_KEY: "unused-for-silent-recordings",
|
||||
OPENAI_BASE_URL: "http://127.0.0.1:9/v1",
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Dictation empty result", () => {
|
||||
test("the daemon accepts the same recording through repeated empty-result retries", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "dictation-empty-",
|
||||
title: "Empty dictation",
|
||||
});
|
||||
await installSyntheticMicrophone(page, { silent: true });
|
||||
const observed = observeDictation(page);
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await recordDictation(page, observed);
|
||||
await expectEmptyDictation(page);
|
||||
await retryDictation(page);
|
||||
await expectEmptyDictation(page);
|
||||
await retryDictation(page);
|
||||
await expectEmptyDictation(page);
|
||||
|
||||
expect(observed.recordings).toHaveLength(3);
|
||||
expect(observed.recordings[0].audio.length).toBeGreaterThan(0);
|
||||
expect(observed.recordings[0].finalSeq).toBe(observed.recordings[0].audio.length - 1);
|
||||
expect(observed.recordings[0].text).toBe("");
|
||||
expect(observed.recordings[1]).toEqual(observed.recordings[0]);
|
||||
expect(observed.recordings[2]).toEqual(observed.recordings[0]);
|
||||
await discardDictation(page);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("narrow composer", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
test("explains and discards an empty result", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "dictation-silent-",
|
||||
title: "Silent dictation",
|
||||
});
|
||||
await installSyntheticMicrophone(page, { silent: true });
|
||||
const observed = observeDictation(page);
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await recordDictation(page, observed);
|
||||
await expectEmptyDictation(page);
|
||||
await discardDictation(page);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -294,17 +294,10 @@ test("creates, renames, copies, and deletes entries through the file explorer",
|
||||
|
||||
await entry("renamed.txt").click({ button: "right" });
|
||||
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const labelColor = await deleteAction
|
||||
.getByText("Delete", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
const iconColor = await deleteAction
|
||||
.locator("svg")
|
||||
.evaluate((element) => getComputedStyle(element).stroke);
|
||||
return labelColor !== "" && iconColor === labelColor;
|
||||
})
|
||||
.toBe(true);
|
||||
const deleteLabelColor = await deleteAction
|
||||
.getByText("Delete", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
|
||||
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
|
||||
@@ -81,8 +81,6 @@ for (const folder of [
|
||||
"docs/guides",
|
||||
"docs/guides/",
|
||||
"docs/指南",
|
||||
"examples/next.js/",
|
||||
"examples/next.js",
|
||||
]) {
|
||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -94,9 +92,6 @@ for (const folder of [
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
|
||||
await mkdir(path.dirname(competingFile), { recursive: true });
|
||||
await writeFile(competingFile, "This is a file, not the requested folder.\n");
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
|
||||
@@ -122,14 +122,10 @@ test.describe("New workspace entry points", () => {
|
||||
});
|
||||
const hostTrigger = page.getByTestId("host-picker-trigger");
|
||||
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
|
||||
const originalUrl = page.url();
|
||||
await hostTrigger.click();
|
||||
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
|
||||
await page.getByTestId("new-workspace-host-picker-add-host").click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Direct connection", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForWorkspaceInSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
async function runCommand(page: Page, title: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
/** Installs a plugin example, opens a seeded workspace, and runs `exercise` against them. */
|
||||
async function withExample(
|
||||
page: Page,
|
||||
example: { name: string; id: string },
|
||||
exercise: (workspace: SeededWorkspace) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const copy = await copyPluginExample(example.name);
|
||||
const workspace = await seedWorkspace({ repoPrefix: `${example.name}-` });
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(copy.directory);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
|
||||
await waitForWorkspaceInSidebar(page, target);
|
||||
await switchWorkspaceViaSidebar({ page, ...target });
|
||||
await exercise(workspace);
|
||||
} finally {
|
||||
try {
|
||||
await client.removePlugin(example.id);
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await workspace.cleanup();
|
||||
await client.close();
|
||||
await copy.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("plugin subscriptions", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("a panel follows events the daemon pushes as files change", async ({ page }) => {
|
||||
await withExample(page, { name: "diff-watch", id: "diff-watch" }, async (workspace) => {
|
||||
await runCommand(page, "Open live changes");
|
||||
await expect(page.getByText("No changes", { exact: true })).toBeVisible();
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "pushed.txt"), "from the test\n");
|
||||
await expect(page.getByText("1 file changed", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("pushed.txt", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("a panel plays streamed bytes and sends bytes back", async ({ page }) => {
|
||||
await withExample(page, { name: "media-stream", id: "media-stream" }, async () => {
|
||||
await runCommand(page, "Open media stream");
|
||||
await expect(page.getByText("live", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/^[1-9]\d* video frames/)).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Send this frame back", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(/^The daemon received [1-9]\d* bytes, sha256 [0-9a-f]{12}$/),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -108,7 +108,10 @@ test.describe("Question option text", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting description text keeps the question open", async ({ page, context }) => {
|
||||
test("selecting description text never answers, and the label still does", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
@@ -117,13 +120,16 @@ test.describe("Question option text", () => {
|
||||
|
||||
await dragAcross(page, description);
|
||||
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
|
||||
await expectChecked(done, false);
|
||||
|
||||
await description.getByText("Logs are at", { exact: false }).dblclick({
|
||||
position: { x: 4, y: 4 },
|
||||
});
|
||||
await expect.poll(() => selectedText(page)).toBe("Logs");
|
||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||
await expectChecked(done, true);
|
||||
await continueToNextQuestion(page);
|
||||
await expectChecked(done, false);
|
||||
|
||||
await done.getByText("Done", { exact: true }).click();
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
await openQuestion(page, { index: 1, total: 2 });
|
||||
await expectChecked(done, true);
|
||||
@@ -132,17 +138,11 @@ test.describe("Question option text", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("double-clicking a single-select label selects once without advancing", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test("double-clicking a single-select label answers once", async ({ page, context }) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
|
||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
|
||||
await continueToNextQuestion(page);
|
||||
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
|
||||
@@ -154,12 +154,14 @@ test.describe("Question option text", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("multi-select descriptions support links and text selection", async ({ page, context }) => {
|
||||
test("multi-select description links and selection leave the option unchanged", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
|
||||
await continueToNextQuestion(page);
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
|
||||
const lint = card.getByRole("checkbox", { name: "Lint" });
|
||||
@@ -175,16 +177,18 @@ test.describe("Question option text", () => {
|
||||
|
||||
await dragAcross(page, description);
|
||||
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await description.getByText("Rules:", { exact: false }).dblclick({
|
||||
position: { x: 4, y: 4 },
|
||||
});
|
||||
await expect.poll(() => selectedText(page)).toBe("Rules");
|
||||
await expectChecked(lint, true);
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
|
||||
await expectChecked(typecheck, true);
|
||||
await lint.getByText("Lint", { exact: true }).click();
|
||||
await expectChecked(lint, false);
|
||||
await expectChecked(lint, true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
@@ -264,7 +268,6 @@ test.describe("Question prompt pagination", () => {
|
||||
await expectQuestionHidden(page, SUCCESS_QUESTION);
|
||||
|
||||
await chooseQuestionOption(page, "App");
|
||||
await continueToNextQuestion(page);
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 2,
|
||||
total: TOTAL_QUESTIONS,
|
||||
@@ -285,7 +288,6 @@ test.describe("Question prompt pagination", () => {
|
||||
preview: 'flags.enabled("new-surface")',
|
||||
});
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await continueToNextQuestion(page);
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 3,
|
||||
total: TOTAL_QUESTIONS,
|
||||
@@ -464,9 +466,7 @@ retryTest(
|
||||
retryTest.setTimeout(180_000);
|
||||
const card = page.getByTestId("question-form-card");
|
||||
await chooseQuestionOption(page, "App");
|
||||
await continueToNextQuestion(page);
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await continueToNextQuestion(page);
|
||||
await fillQuestionAnswer(page, {
|
||||
question: SUCCESS_QUESTION,
|
||||
answer: "Preserve my answers after a failed submission.",
|
||||
@@ -520,9 +520,7 @@ retryTest(
|
||||
const card = page.getByTestId("question-form-card");
|
||||
questionClient.releaseFirstResponse();
|
||||
await chooseQuestionOption(page, "App");
|
||||
await continueToNextQuestion(page);
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await continueToNextQuestion(page);
|
||||
await fillQuestionAnswer(page, {
|
||||
question: SUCCESS_QUESTION,
|
||||
answer: "Start with the internal team.",
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
fillNewWorkspaceDraft,
|
||||
expectNewWorkspaceDraft,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { readFile, mkdir, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -278,71 +273,3 @@ test("restart recovery host controls persist the opt-out and custom prompt", asy
|
||||
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||
await editRestartRecoveryPrompt(page, "Resume working.");
|
||||
});
|
||||
|
||||
test("sidebar Add host preserves its caller and stays closed after theme changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).toHaveURL(/\/open-project$/);
|
||||
const originalUrl = page.url();
|
||||
await openSidebarAddHost(page);
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await closeAddHostAndChangeTheme(page);
|
||||
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
});
|
||||
|
||||
async function openSidebarAddHost(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Hosts", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Add host", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeAddHostAndChangeTheme(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
|
||||
await page.keyboard.press("Control+Alt+t");
|
||||
}
|
||||
|
||||
test("sidebar Add host completes without navigating", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).toHaveURL(/\/open-project$/);
|
||||
const originalUrl = page.url();
|
||||
await openSidebarAddHost(page);
|
||||
await connectToTestHost(page);
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await expect(page.getByRole("button", { name: "Hosts", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Settings Add host opens the saved host Overview", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: Number(getE2EDaemonPort()) });
|
||||
await expectSettingsHeader(page, "Overview");
|
||||
await expect(page).toHaveURL(new RegExp(`/settings/hosts/${getServerId()}/host$`));
|
||||
});
|
||||
|
||||
test("New workspace Add host preserves the prompt after connecting", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await fillNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
|
||||
const originalUrl = page.url();
|
||||
await openNewWorkspaceAddHost(page);
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await connectToTestHost(page);
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await expectNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
|
||||
});
|
||||
|
||||
async function openNewWorkspaceAddHost(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page.getByRole("button", { name: "Add host", exact: true }).click();
|
||||
}
|
||||
|
||||
async function connectToTestHost(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Direct connection", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Host", exact: true }).fill("127.0.0.1");
|
||||
await page.getByRole("textbox", { name: "Port", exact: true }).fill(String(getE2EDaemonPort()));
|
||||
await page.getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await expect(page.getByTestId("add-host-modal")).toBeHidden({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
@@ -38,9 +38,6 @@ import {
|
||||
fillDirectHostUri,
|
||||
expectDirectHostFormValues,
|
||||
expectDirectHostSslEnabled,
|
||||
expectDirectHostSslDisabled,
|
||||
fillDirectHostAddress,
|
||||
toggleDirectHostSsl,
|
||||
expectDirectHostUriValue,
|
||||
expectDirectHostUriHidden,
|
||||
expectDiagnosticsContent,
|
||||
@@ -287,16 +284,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expectAddHostMethodOptions(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
|
||||
await fillDirectHostAddress(page, "example.paseo.test", "7443");
|
||||
await expectDirectHostSslDisabled(page);
|
||||
await toggleDirectHostSsl(page);
|
||||
await expectDirectHostSslEnabled(page);
|
||||
await toggleHostAdvanced(page);
|
||||
await expectDirectHostUriValue(page, "tcp://example.paseo.test:7443?ssl=true");
|
||||
await toggleHostAdvanced(page);
|
||||
await toggleDirectHostSsl(page);
|
||||
await expectDirectHostSslDisabled(page);
|
||||
|
||||
await toggleHostAdvanced(page);
|
||||
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
||||
await toggleHostAdvanced(page);
|
||||
|
||||
@@ -426,81 +426,3 @@ test("Manual restores pre-beta order and keeps drag order across sorting and rel
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
function workspaceRows(page: Page, ids: string[]) {
|
||||
return page.locator(
|
||||
ids.map((id) => `[data-testid="sidebar-workspace-row-${getServerId()}:${id}"]`).join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
async function openWorkspaceRow(page: Page, workspaceId: string) {
|
||||
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
|
||||
}
|
||||
|
||||
test("collapsed groups lead with the open workspace instead of hiding it behind Show more", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { seed, ids } = await seedCompactProject();
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
const { rows, group } = compactProjectRows(page, seed);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
await revealWorkspaces(group, "Show more");
|
||||
await openWorkspaceRow(page, ids[9]!);
|
||||
await expectWorkspaceOrder(rows, ids);
|
||||
await revealWorkspaces(group, "Show less");
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await reconnectSidebar(page);
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await openWorkspaceRow(page, ids[2]!);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[2]!}`),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a Manual drag in a collapsed group keeps the open workspace's saved place", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { seed, ids } = await seedCompactProject();
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
const projectKey = projectEquivalenceViewKey(seed.projectKey);
|
||||
await restorePreBetaOrder(
|
||||
page,
|
||||
projectKey,
|
||||
ids.map((id) => `${getServerId()}:${id}`),
|
||||
);
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
const rows = workspaceRows(page, ids);
|
||||
const toggle = page.getByTestId(`sidebar-project-show-more-${projectKey}`);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
await toggle.click();
|
||||
await openWorkspaceRow(page, ids[9]!);
|
||||
await toggle.click();
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
||||
await quickDragFirstRowAfterSecond(workspaceRows(page, [ids[0]!, ids[1]!]), pressWorkspaceRow);
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
|
||||
await reconnectSidebar(page);
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
|
||||
await toggle.click();
|
||||
await expectWorkspaceOrder(rows, [ids[1]!, ids[0]!, ...ids.slice(2)]);
|
||||
} finally {
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -70,31 +70,25 @@ test("previous and next traverse every visible workspace while digit shortcuts s
|
||||
workspaceId: order[8],
|
||||
});
|
||||
});
|
||||
await test.step("collapsed groups keep shortcut targets selected without expanding", async () => {
|
||||
await expectCollapsedWorkspaceShortcuts(page, order);
|
||||
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
|
||||
await expectHiddenWorkspaceShortcuts(page, order);
|
||||
});
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectCollapsedWorkspaceShortcuts(page: Page, order: string[]) {
|
||||
async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) {
|
||||
await page.getByLabel("Show less", { exact: true }).click();
|
||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||
await expect(rows).toHaveCount(8);
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||
await expect(rows).toHaveCount(7);
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||
await page.keyboard.press("Alt+9");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||
await expect(rows).toHaveCount(8);
|
||||
await page.keyboard.press("Alt+]");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[9] });
|
||||
await expect(rows).toHaveCount(8);
|
||||
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByLabel("Show more", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Alt+]");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
|
||||
await expect(rows).toHaveCount(7);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import xterm from "@xterm/headless";
|
||||
import { PNG } from "pngjs";
|
||||
import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs";
|
||||
@@ -83,8 +82,6 @@ async function fixtureTerminal(size) {
|
||||
rows: size.rows,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
// The app's buffer uses grapheme widths; the expected cells must too.
|
||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||
await new Promise((done) =>
|
||||
terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done),
|
||||
);
|
||||
|
||||
@@ -113,7 +113,3 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, type Page } from "../fixtures";
|
||||
|
||||
/**
|
||||
* Replaces the browser microphone with an oscillator. `silent` keeps the stream
|
||||
* running at zero gain, which is what a muted or wrongly routed input delivers.
|
||||
*/
|
||||
export async function installSyntheticMicrophone(
|
||||
page: Page,
|
||||
options: { silent?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await page.addInitScript((silent: boolean) => {
|
||||
const mediaDevices = navigator.mediaDevices;
|
||||
Object.defineProperty(mediaDevices, "getUserMedia", {
|
||||
configurable: true,
|
||||
value: async () => {
|
||||
const context = new AudioContext();
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = silent ? 0 : 1;
|
||||
const destination = context.createMediaStreamDestination();
|
||||
oscillator.connect(gain);
|
||||
gain.connect(destination);
|
||||
oscillator.start();
|
||||
return destination.stream;
|
||||
},
|
||||
});
|
||||
}, options.silent ?? false);
|
||||
}
|
||||
|
||||
/** Observe real browser/daemon traffic without intercepting or answering it. */
|
||||
export function observeDictation(page: Page) {
|
||||
const streams = new Map<string, { audio: Map<number, string>; finalSeq: number }>();
|
||||
const recordings: Array<{ audio: string[]; finalSeq: number; text: string }> = [];
|
||||
let resolveAudio!: () => void;
|
||||
const audio = new Promise<void>((resolve) => {
|
||||
resolveAudio = resolve;
|
||||
});
|
||||
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
const request = sessionMessage(payload);
|
||||
if (!request || typeof request.dictationId !== "string") return;
|
||||
const id = request.dictationId;
|
||||
if (request.type === "dictation_stream_start") {
|
||||
streams.set(id, { audio: new Map(), finalSeq: -1 });
|
||||
}
|
||||
if (request.type === "dictation_stream_chunk") {
|
||||
streams.get(id)!.audio.set(request.seq as number, request.audio as string);
|
||||
resolveAudio();
|
||||
}
|
||||
if (request.type === "dictation_stream_finish") {
|
||||
streams.get(id)!.finalSeq = request.finalSeq as number;
|
||||
}
|
||||
});
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
const response = sessionMessage(payload);
|
||||
if (response?.type !== "dictation_stream_final") return;
|
||||
const final = response.payload as { dictationId: string; text: string };
|
||||
const stream = streams.get(final.dictationId)!;
|
||||
recordings.push({
|
||||
audio: [...stream.audio.entries()].sort(([a], [b]) => a - b).map(([, chunk]) => chunk),
|
||||
finalSeq: stream.finalSeq,
|
||||
text: final.text,
|
||||
});
|
||||
});
|
||||
});
|
||||
return { waitForAudio: () => audio, recordings };
|
||||
}
|
||||
|
||||
function sessionMessage(payload: string | Buffer): Record<string, unknown> | null {
|
||||
try {
|
||||
const envelope = JSON.parse(typeof payload === "string" ? payload : payload.toString("utf8"));
|
||||
return envelope.type === "session" ? envelope.message : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordDictation(page: Page, observed: { waitForAudio(): Promise<void> }) {
|
||||
await page.getByRole("button", { name: "Start dictation", exact: true }).click();
|
||||
await observed.waitForAudio();
|
||||
await page.getByRole("button", { name: "Insert transcription", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectEmptyDictation(page: Page) {
|
||||
const message = page.getByText("No transcription was returned. Tap retry to try again.", {
|
||||
exact: true,
|
||||
});
|
||||
await expect(message).toBeVisible();
|
||||
const bounds = await message.boundingBox();
|
||||
const cancel = await page
|
||||
.getByRole("button", { name: "Cancel dictation", exact: true })
|
||||
.boundingBox();
|
||||
expect(bounds).not.toBeNull();
|
||||
expect(cancel).not.toBeNull();
|
||||
expect(bounds!.y).toBeGreaterThanOrEqual(cancel!.y);
|
||||
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(cancel!.y + cancel!.height);
|
||||
}
|
||||
|
||||
export async function retryDictation(page: Page) {
|
||||
await page.getByRole("button", { name: "Retry dictation", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function discardDictation(page: Page) {
|
||||
await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
|
||||
}
|
||||
@@ -273,25 +273,3 @@ export async function reloadPreservingHostRegistry(page: Page): Promise<void> {
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function disconnectAllHostsAndReload(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ registryKey, nonceKey, disableSeedKey }) => {
|
||||
const nonce = localStorage.getItem(nonceKey);
|
||||
if (!nonce) throw new Error("Expected the e2e seed nonce");
|
||||
const hosts = JSON.parse(localStorage.getItem(registryKey) ?? "[]");
|
||||
for (const host of hosts) {
|
||||
host.connections = [{ id: "offline", type: "directTcp", endpoint: "127.0.0.1:59999" }];
|
||||
host.preferredConnectionId = "offline";
|
||||
}
|
||||
localStorage.setItem(registryKey, JSON.stringify(hosts));
|
||||
localStorage.setItem(disableSeedKey, nonce);
|
||||
},
|
||||
{
|
||||
registryKey: REGISTRY_KEY,
|
||||
nonceKey: SEED_NONCE_KEY,
|
||||
disableSeedKey: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
@@ -182,8 +182,8 @@ export async function startIsolatedHostDaemon(
|
||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||
const spawnOptions: SpawnOptions = {
|
||||
cwd: serverDir,
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
env: {
|
||||
...withDisabledE2ESpeechEnv(process.env),
|
||||
...options.environment,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: serverId,
|
||||
@@ -192,7 +192,7 @@ export async function startIsolatedHostDaemon(
|
||||
PASEO_RELAY_ENABLED: options.mutableRelay ? undefined : "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
}),
|
||||
},
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
};
|
||||
|
||||
@@ -351,24 +351,9 @@ export async function expectDirectHostFormValues(
|
||||
}
|
||||
|
||||
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).toBeChecked();
|
||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function fillDirectHostAddress(page: Page, host: string, port: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Host", exact: true }).fill(host);
|
||||
await page.getByRole("textbox", { name: "Port", exact: true }).fill(port);
|
||||
}
|
||||
|
||||
export async function toggleDirectHostSsl(page: Page): Promise<void> {
|
||||
await page.getByRole("checkbox", { name: "Use SSL", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectDirectHostSslDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).not.toBeChecked();
|
||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
|
||||
}
|
||||
|
||||
+10
-10
@@ -1,21 +1,21 @@
|
||||
[
|
||||
{
|
||||
"output": "स्ते",
|
||||
"row": "4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
|
||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||
"row": "2,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
|
||||
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
},
|
||||
{
|
||||
"output": "क्ष",
|
||||
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
|
||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
|
||||
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
},
|
||||
{
|
||||
"output": "নক্ষত্র",
|
||||
"row": "1,1,3,1,3,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
|
||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||
"row": "1,1,2,1,1,1,2,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
|
||||
"clusterStarts": [0, 1, 3, 5, 6, 7],
|
||||
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
},
|
||||
{
|
||||
"output": "क्अ",
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
{
|
||||
"output": "अ्क",
|
||||
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
|
||||
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
|
||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||
}
|
||||
|
||||
+8
-35
@@ -1,5 +1,6 @@
|
||||
package sh.paseo.terminal
|
||||
|
||||
import android.os.Build
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
@@ -9,7 +10,6 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@@ -43,45 +43,18 @@ class TerminalRowLayoutTest {
|
||||
val output = row.getString("output")
|
||||
val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast)
|
||||
Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast")
|
||||
// Android 10's platform ICU splits this conjunct. Keep its observed raster baseline
|
||||
// explicit rather than claiming this supported OS passes the modern grapheme corpus.
|
||||
if (Build.VERSION.SDK_INT == 29 && output == "स्ते") {
|
||||
assertTrue("Android 10 unexpectedly matches modern conjunct clustering", toExpected > MATCH_TOLERANCE)
|
||||
assertTrue("Android 10 split-conjunct baseline changed", toContrast <= MATCH_TOLERANCE)
|
||||
continue
|
||||
}
|
||||
assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE)
|
||||
assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() {
|
||||
val red = 0xffff0000.toInt()
|
||||
val green = 0xff00aa00.toInt()
|
||||
val overlay = 0x472684ff
|
||||
val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M")
|
||||
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
|
||||
paints.selectionBackground = overlay
|
||||
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
|
||||
val actual = raster { canvas ->
|
||||
canvas.drawColor(terminalBackground)
|
||||
layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
|
||||
}
|
||||
val expected = raster { canvas ->
|
||||
canvas.drawColor(terminalBackground)
|
||||
canvas.drawColor(green)
|
||||
canvas.drawColor(overlay)
|
||||
}
|
||||
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
|
||||
assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y ->
|
||||
(0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red }
|
||||
})
|
||||
}
|
||||
val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY)
|
||||
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
|
||||
val actual = raster { canvas ->
|
||||
canvas.drawColor(terminalBackground)
|
||||
defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
|
||||
}
|
||||
val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) }
|
||||
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun starts(row: JSONObject, key: String): List<Int> =
|
||||
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
|
||||
|
||||
|
||||
+1
@@ -63,6 +63,7 @@ class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(con
|
||||
backgroundColor = argb(value.background)
|
||||
cursorColor = argb(value.cursor)
|
||||
paints.selectionBackground = argb(value.selectionBackground)
|
||||
paints.selectionForeground = argb(value.selectionForeground)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ class TerminalThemeRecord : Record {
|
||||
@Field val background: Double = 0.0
|
||||
@Field val cursor: Double = 0.0
|
||||
@Field val selectionBackground: Double = 0.0
|
||||
@Field val selectionForeground: Double = 0.0
|
||||
}
|
||||
|
||||
class TerminalCustomGlyphRecord : Record {
|
||||
|
||||
+21
-17
@@ -23,7 +23,7 @@ private const val CLIP_UNBOUNDED_PX = 100_000f
|
||||
* either half alone would show an explicit virama. Each resulting group is drawn from the shaped
|
||||
* run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when
|
||||
* its glyphs are wider than those columns. Colors never split groups or reshape the row: a group
|
||||
* whose cells differ in color is drawn once per color, clipped to those cells' columns.
|
||||
* whose cells differ in color or selection is drawn once per color, clipped to those cells' columns.
|
||||
*/
|
||||
internal class TerminalRowLayout(
|
||||
val source: TerminalRowSource,
|
||||
@@ -195,7 +195,7 @@ internal class TerminalRowLayout(
|
||||
canvas.drawRenderNode(recorded)
|
||||
}
|
||||
|
||||
/** Draws backgrounds, the selection overlay, then text with its original colors. */
|
||||
/** Draws the row with selected cells in selection colors. Selection never reshapes the row. */
|
||||
fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) {
|
||||
drawContent(canvas, row, selection, paints)
|
||||
}
|
||||
@@ -213,17 +213,17 @@ internal class TerminalRowLayout(
|
||||
val cellHeight = font.cellHeightPx.toFloat()
|
||||
val fill = paints.fill
|
||||
for (cell in 0 until cellCount) {
|
||||
val left = cellCol[cell] * cellWidth
|
||||
val right = (cellCol[cell] + cellSpan[cell]) * cellWidth
|
||||
val background = cellBackground[cell]
|
||||
if (background ushr 24 != 0) {
|
||||
fill.color = background
|
||||
canvas.drawRect(left, 0f, right, cellHeight, fill)
|
||||
}
|
||||
if (selected(row, selection, cell)) {
|
||||
fill.color = paints.selectionBackground
|
||||
canvas.drawRect(left, 0f, right, cellHeight, fill)
|
||||
}
|
||||
val background =
|
||||
if (selected(row, selection, cell)) paints.selectionBackground else cellBackground[cell]
|
||||
if (background ushr 24 == 0) continue
|
||||
fill.color = background
|
||||
canvas.drawRect(
|
||||
cellCol[cell] * cellWidth,
|
||||
0f,
|
||||
(cellCol[cell] + cellSpan[cell]) * cellWidth,
|
||||
cellHeight,
|
||||
fill,
|
||||
)
|
||||
}
|
||||
|
||||
val baseline = font.baselinePx.toFloat()
|
||||
@@ -234,9 +234,9 @@ internal class TerminalRowLayout(
|
||||
val paint = font.paint(cellFlags[group.firstCell])
|
||||
var first = group.firstCell
|
||||
while (first <= group.lastCell) {
|
||||
val color = cellForeground[first]
|
||||
val color = foreground(row, selection, first, paints)
|
||||
var last = first
|
||||
while (last < group.lastCell && cellForeground[last + 1] == color) last += 1
|
||||
while (last < group.lastCell && foreground(row, selection, last + 1, paints) == color) last += 1
|
||||
// Only edges between differently colored cells clip; the group's outer edges may overhang.
|
||||
val clipped = first > group.firstCell || last < group.lastCell
|
||||
canvas.save()
|
||||
@@ -257,7 +257,7 @@ internal class TerminalRowLayout(
|
||||
for (cell in 0 until cellCount) {
|
||||
val glyph = cellGlyph[cell]
|
||||
val flags = cellFlags[cell]
|
||||
val foreground = cellForeground[cell]
|
||||
val foreground = foreground(row, selection, cell, paints)
|
||||
if (glyph != null) {
|
||||
fill.color = foreground
|
||||
glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill)
|
||||
@@ -291,6 +291,9 @@ internal class TerminalRowLayout(
|
||||
canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint)
|
||||
}
|
||||
|
||||
private fun foreground(row: Int, selection: TerminalSelection?, cell: Int, paints: TerminalPaints): Int =
|
||||
if (selected(row, selection, cell)) paints.selectionForeground else cellForeground[cell]
|
||||
|
||||
/** The row's text as accessibility reads it. */
|
||||
fun describe(): String = source.text.trimEnd()
|
||||
}
|
||||
@@ -298,5 +301,6 @@ internal class TerminalRowLayout(
|
||||
/** Paint state shared by every row of one view. */
|
||||
internal class TerminalPaints {
|
||||
val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
var selectionBackground: Int = 0x472684FF
|
||||
var selectionBackground: Int = 0xFFFFFF00.toInt()
|
||||
var selectionForeground: Int = 0xFF000000.toInt()
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface PaseoTerminalTheme {
|
||||
background: number;
|
||||
cursor: number;
|
||||
selectionBackground: number;
|
||||
selectionForeground: number;
|
||||
}
|
||||
|
||||
export interface PaseoTerminalCellMetrics {
|
||||
|
||||
@@ -24,7 +24,8 @@ final class PaseoTerminalView: ExpoView {
|
||||
private var background = TerminalColor(Double(0xff00_0000))
|
||||
private var cursorColor = TerminalColor(Double(0xffff_ffff))
|
||||
private var rowPaint = TerminalRowPaint(
|
||||
selectionBackground: TerminalColor(Double(0x4726_84ff)))
|
||||
selectionBackground: TerminalColor(Double(0xffff_ff00)),
|
||||
selectionForeground: TerminalColor(Double(0xff00_0000)))
|
||||
private var rowCache: [String: TerminalRowLayout] = [:]
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
@@ -43,7 +44,8 @@ final class PaseoTerminalView: ExpoView {
|
||||
background = TerminalColor(record.background)
|
||||
cursorColor = TerminalColor(record.cursor)
|
||||
rowPaint = TerminalRowPaint(
|
||||
selectionBackground: TerminalColor(record.selectionBackground))
|
||||
selectionBackground: TerminalColor(record.selectionBackground),
|
||||
selectionForeground: TerminalColor(record.selectionForeground))
|
||||
backgroundColor = UIColor(cgColor: background.cgColor())
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ struct TerminalThemeRecord: Record {
|
||||
@Field var background: Double = 0
|
||||
@Field var cursor: Double = 0
|
||||
@Field var selectionBackground: Double = 0
|
||||
@Field var selectionForeground: Double = 0
|
||||
}
|
||||
|
||||
struct TerminalCustomGlyphRecord: Record {
|
||||
|
||||
@@ -27,6 +27,7 @@ private let leftToRightAttributes: [NSAttributedString.Key: Any] = {
|
||||
/// Colors a row is painted with besides its own styles.
|
||||
struct TerminalRowPaint {
|
||||
let selectionBackground: TerminalColor
|
||||
let selectionForeground: TerminalColor
|
||||
}
|
||||
|
||||
/// One row shaped once and placed on the terminal grid.
|
||||
@@ -266,33 +267,31 @@ final class TerminalRowLayout {
|
||||
selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false
|
||||
}
|
||||
|
||||
/// Draws cell backgrounds, the selection overlay, then text with its original colors.
|
||||
private func foreground(_ cell: Int, row: Int, selection: TerminalSelection?, paint: TerminalRowPaint) -> TerminalColor {
|
||||
isSelected(cell, row: row, selection: selection) ? paint.selectionForeground : cellForeground[cell]
|
||||
}
|
||||
|
||||
/// Draws the row with its top edge at `top`, selected cells in selection colors.
|
||||
func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) {
|
||||
let cellWidth = font.cellWidth
|
||||
let cellHeight = font.cellHeight
|
||||
for cell in 0..<cellCount {
|
||||
let rect = CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight)
|
||||
let background = cellBackground[cell]
|
||||
if background.isPainted {
|
||||
context.setFillColor(background.cgColor())
|
||||
context.fill(rect)
|
||||
}
|
||||
if isSelected(cell, row: row, selection: selection) {
|
||||
context.setFillColor(paint.selectionBackground.cgColor())
|
||||
context.fill(rect)
|
||||
}
|
||||
let background = isSelected(cell, row: row, selection: selection) ? paint.selectionBackground : cellBackground[cell]
|
||||
guard background.isPainted else { continue }
|
||||
context.setFillColor(background.cgColor())
|
||||
context.fill(CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight))
|
||||
}
|
||||
|
||||
let centerY = top + cellHeight / 2
|
||||
let baselineInTextSpace = cellHeight / 2 - font.baseline
|
||||
// A group whose cells differ in color is drawn once per color, clipped to those
|
||||
// A group whose cells differ in color or selection is drawn once per color, clipped to those
|
||||
// cells' columns. Only edges between differently colored cells clip; outer edges may overhang.
|
||||
for group in groups {
|
||||
var first = group.firstCell
|
||||
while first <= group.lastCell {
|
||||
let color = cellForeground[first]
|
||||
let color = foreground(first, row: row, selection: selection, paint: paint)
|
||||
var last = first
|
||||
while last < group.lastCell && cellForeground[last + 1] == color {
|
||||
while last < group.lastCell && foreground(last + 1, row: row, selection: selection, paint: paint) == color {
|
||||
last += 1
|
||||
}
|
||||
context.saveGState()
|
||||
@@ -316,7 +315,7 @@ final class TerminalRowLayout {
|
||||
|
||||
let baseline = top + font.baseline
|
||||
for cell in 0..<cellCount {
|
||||
let color = cellForeground[cell]
|
||||
let color = foreground(cell, row: row, selection: selection, paint: paint)
|
||||
let left = CGFloat(cellCol[cell]) * cellWidth
|
||||
let width = CGFloat(cellSpan[cell]) * cellWidth
|
||||
if let glyph = cellGlyph[cell] {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -71,14 +71,13 @@
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
"@xterm/addon-fit": "^0.12.0-beta.213",
|
||||
"@xterm/addon-image": "0.10.0-beta.331",
|
||||
"@xterm/addon-image": "^0.10.0-beta.213",
|
||||
"@xterm/addon-ligatures": "^0.11.0-beta.213",
|
||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.329",
|
||||
"@xterm/xterm": "6.1.0-beta.336",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.291",
|
||||
"@xterm/xterm": "6.1.0-beta.292",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~57.0.30",
|
||||
"expo-asset": "~57.0.20",
|
||||
@@ -156,7 +155,7 @@
|
||||
"@vitest/browser": "^4.1.7",
|
||||
"@vitest/browser-playwright": "^4.1.7",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "6.1.0-beta.334",
|
||||
"@xterm/headless": "^6.1.0-beta.213",
|
||||
"babel-preset-expo": "~57.0.16",
|
||||
"dotenv": "^17.2.3",
|
||||
"eas-cli": "^16.24.1",
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ComposerEditingSession } from "@/composer/editing-session";
|
||||
import { agentCommandsQueryKey } from "./cache";
|
||||
import {
|
||||
useAgentCommandsQuery,
|
||||
type AgentSlashCommand,
|
||||
type DraftCommandConfig,
|
||||
} from "./use-query";
|
||||
|
||||
export type {
|
||||
AgentSlashCommand,
|
||||
DraftCommandConfig,
|
||||
DraftCommandTarget,
|
||||
AgentCommandsClient,
|
||||
} from "./use-query";
|
||||
export { fetchAgentCommands } from "./use-query";
|
||||
|
||||
type CommandQuery = ReturnType<typeof useAgentCommandsQuery>;
|
||||
const QueryContext = createContext<CommandQuery | null>(null);
|
||||
export interface CommandHints {
|
||||
names: ReadonlySet<string>;
|
||||
insert(command: string): void;
|
||||
}
|
||||
const HintsContext = createContext<CommandHints | null>(null);
|
||||
|
||||
function useLiveAgentCommands() {
|
||||
return useContext(QueryContext);
|
||||
}
|
||||
export function useAutocompleteCommands(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
enabled: boolean;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
}) {
|
||||
const live = useLiveAgentCommands();
|
||||
const fallback = useAgentCommandsQuery({
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
enabled: !live && input.enabled,
|
||||
draftConfig: input.draftConfig,
|
||||
});
|
||||
return live ?? fallback;
|
||||
}
|
||||
export function useAgentCommandHints() {
|
||||
return useContext(HintsContext);
|
||||
}
|
||||
export function matchCommandLink(
|
||||
url: string,
|
||||
sourceType: "markdown" | "text" | "inline-code" | undefined,
|
||||
names: ReadonlySet<string>,
|
||||
): string | null {
|
||||
return (sourceType === "markdown" || sourceType === "inline-code") &&
|
||||
url.startsWith("/") &&
|
||||
names.has(url.slice(1))
|
||||
? url
|
||||
: null;
|
||||
}
|
||||
|
||||
export function AgentCommandsProvider({
|
||||
serverId,
|
||||
agentId,
|
||||
editingSession,
|
||||
enabled,
|
||||
children,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
editingSession: ComposerEditingSession;
|
||||
enabled: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const query = useAgentCommandsQuery({ serverId, agentId, enabled });
|
||||
const queryClient = useQueryClient();
|
||||
const namesKey = JSON.stringify(
|
||||
[...new Set(query.commands.map((command) => command.name))].sort(),
|
||||
);
|
||||
const names = useMemo<ReadonlySet<string>>(
|
||||
() => new Set(JSON.parse(namesKey) as string[]),
|
||||
[namesKey],
|
||||
);
|
||||
const insert = useCallback(
|
||||
(token: string) => {
|
||||
const commands = queryClient.getQueryData<AgentSlashCommand[]>(
|
||||
agentCommandsQueryKey({ serverId, agentId }),
|
||||
);
|
||||
if (token.startsWith("/") && commands?.some((command) => `/${command.name}` === token)) {
|
||||
editingSession.insertCommand(token);
|
||||
}
|
||||
},
|
||||
[agentId, editingSession, queryClient, serverId],
|
||||
);
|
||||
const hints = useMemo(() => ({ names, insert }), [names, insert]);
|
||||
return (
|
||||
<QueryContext.Provider value={enabled ? query : null}>
|
||||
<HintsContext.Provider value={enabled ? hints : null}>{children}</HintsContext.Provider>
|
||||
</QueryContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -69,9 +69,6 @@ function installProbe(
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
subscribe: () => {
|
||||
throw new Error("Subscriptions are not used here");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -468,7 +468,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
workspaceRoot,
|
||||
resolveRelativeFiles: true,
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
|
||||
@@ -466,7 +465,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const surface = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
<AddHostFlowHost />
|
||||
<AppearanceStyleBoundary>
|
||||
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
|
||||
<WindowChromeRegion corners="top-left">
|
||||
@@ -807,6 +805,7 @@ function RootStack() {
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="schedules" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
@@ -891,13 +890,11 @@ function RootAppTree() {
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
|
||||
<AddHostProvider>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</AddHostProvider>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</LucideProvider>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
+59
-24
@@ -1,14 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { openPairScan } from "./pair-scan-model";
|
||||
import { openPairScan } from "@/hosts/pair-scan-model";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -110,26 +113,50 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export function PairScanContent({
|
||||
onConnected,
|
||||
onClose,
|
||||
}: {
|
||||
onConnected: (serverId: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
export default function PairScanScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
source?: string;
|
||||
}>();
|
||||
const source = typeof params.source === "string" ? params.source : "settings";
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
|
||||
const navigateToPairedHost = useCallback(
|
||||
(serverId: string) => {
|
||||
if (source === "onboarding") {
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
return;
|
||||
}
|
||||
router.replace(buildSettingsHostRoute(serverId));
|
||||
},
|
||||
[router, source],
|
||||
);
|
||||
|
||||
const [scan] = useState(() =>
|
||||
openPairScan({
|
||||
importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"),
|
||||
onConnected,
|
||||
importConnectionLink: (link) =>
|
||||
getHostRuntimeStore().importConnectionLink(
|
||||
link,
|
||||
source === "onboarding" ? "hostRoot" : "hostSettings",
|
||||
),
|
||||
onConnected: navigateToPairedHost,
|
||||
}),
|
||||
);
|
||||
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
|
||||
useEffect(() => () => scan.close(), [scan]);
|
||||
|
||||
const closeToSource = useCallback(() => {
|
||||
try {
|
||||
router.back();
|
||||
} catch {
|
||||
router.replace("/" as Href);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb) return;
|
||||
if (permission && permission.granted) return;
|
||||
@@ -142,29 +169,35 @@ export function PairScanContent({
|
||||
[scan],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
const savePasswordPairing = useCallback(
|
||||
({ serverId }: { serverId: string }) => onConnected(serverId),
|
||||
[onConnected],
|
||||
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
||||
[navigateToPairedHost],
|
||||
);
|
||||
const handleRequestPermission = useCallback(() => {
|
||||
void requestPermission();
|
||||
}, [requestPermission]);
|
||||
|
||||
const bodyStyle = useMemo(
|
||||
() => [styles.body, { paddingBottom: insets.bottom + 24 }],
|
||||
[insets.bottom],
|
||||
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
|
||||
[insets.bottom, theme.spacing],
|
||||
);
|
||||
const helperTextStyle = useMemo(
|
||||
() => [styles.helperText, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
|
||||
if (isWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
|
||||
<View style={bodyStyle}>
|
||||
<View style={styles.permissionCard}>
|
||||
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
|
||||
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
|
||||
<Button style={styles.permissionButton} onPress={onClose}>
|
||||
<Text style={styles.permissionButtonText}>{t("common.actions.back")}</Text>
|
||||
</Button>
|
||||
<Pressable style={styles.permissionButton} onPress={closeToSource}>
|
||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -175,14 +208,16 @@ export function PairScanContent({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
|
||||
|
||||
<View style={bodyStyle}>
|
||||
{!granted ? (
|
||||
<View style={styles.permissionCard}>
|
||||
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
|
||||
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
|
||||
<Button style={styles.permissionButton} onPress={handleRequestPermission}>
|
||||
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
|
||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
|
||||
</Button>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.cameraWrap}>
|
||||
@@ -200,10 +235,10 @@ export function PairScanContent({
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
{scanState.status === "pairing" ? (
|
||||
<Text style={styles.helperText}>{t("pairing.scan.pairing")}</Text>
|
||||
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" && scanState.error ? (
|
||||
<Text style={styles.helperText}>{scanState.error}</Text>
|
||||
<Text style={helperTextStyle}>{scanState.error}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" ? (
|
||||
<Button
|
||||
@@ -35,9 +35,10 @@ function SettingsDaemonRedirect() {
|
||||
}
|
||||
|
||||
export default function SettingsSectionRoute() {
|
||||
const params = useLocalSearchParams<{ section?: string }>();
|
||||
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
||||
const rawSection = typeof params.section === "string" ? params.section : "";
|
||||
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
|
||||
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
|
||||
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
||||
|
||||
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
|
||||
@@ -49,5 +50,5 @@ export default function SettingsSectionRoute() {
|
||||
);
|
||||
}
|
||||
|
||||
return <SettingsScreen view={view} />;
|
||||
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
||||
}
|
||||
|
||||
@@ -64,10 +64,7 @@ it("ignores an older probe after a newer file link opens", async () => {
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: {
|
||||
listDirectory: () => pendingProbe,
|
||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
||||
},
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
@@ -94,10 +91,7 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: {
|
||||
listDirectory: () => pendingProbe,
|
||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
||||
},
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
@@ -136,45 +130,3 @@ it("sends folders outside the workspace away from the workspace Files panel", as
|
||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||
expect(workspaceFolders).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
it("keeps a later selection when an earlier nested-file lookup finishes last", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
const lookup = Promise.withResolvers<{
|
||||
entries: Array<{ path: string; kind: "file" }>;
|
||||
error: null;
|
||||
}>();
|
||||
const queries: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
resolveRelativeFiles: true,
|
||||
client: {
|
||||
listDirectory: async () => {
|
||||
throw new Error("Not a directory");
|
||||
},
|
||||
getDirectorySuggestions: async ({ query }: { query: string }) => {
|
||||
queries.push(query);
|
||||
return lookup.promise;
|
||||
},
|
||||
},
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const earlier = opener.open(
|
||||
{ raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 },
|
||||
"side",
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(queries).toEqual(["schema/x.md"]);
|
||||
await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side");
|
||||
lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null });
|
||||
await earlier;
|
||||
expect(opened).toEqual(["guide.md"]);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type OpenFileDisposition,
|
||||
} from "@/workspace/file-open";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver";
|
||||
|
||||
interface PathOpenInput {
|
||||
file?: string;
|
||||
@@ -51,11 +50,7 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathO
|
||||
|
||||
export function createInlinePathOpener(input: {
|
||||
workspaceRoot: string;
|
||||
client: {
|
||||
listDirectory(root: string, path: string): Promise<unknown>;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
} | null;
|
||||
resolveRelativeFiles?: boolean;
|
||||
client: PathOpenInput["client"];
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
openOutsideDirectory: (path: string) => void;
|
||||
@@ -78,21 +73,10 @@ export function createInlinePathOpener(input: {
|
||||
} else if (destination.kind === "outsideDirectory") {
|
||||
input.openOutsideDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const resolvedTarget =
|
||||
input.resolveRelativeFiles && input.client
|
||||
? await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client),
|
||||
})
|
||||
: target;
|
||||
if (ownGeneration !== generation) return;
|
||||
const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot);
|
||||
if (!resolved?.file) return;
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: resolved.file,
|
||||
lineStart: resolvedTarget.lineStart,
|
||||
lineEnd: resolvedTarget.lineEnd,
|
||||
path: normalized.file,
|
||||
lineStart: target.lineStart,
|
||||
lineEnd: target.lineEnd,
|
||||
});
|
||||
if (location) input.openFile(location, disposition);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
getAssistantFileLinkToken,
|
||||
resolveMissingNestedRelativeFile,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkContext,
|
||||
type DirectorySuggestionEntry,
|
||||
@@ -425,174 +424,6 @@ describe("fetchDaemonResolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMissingNestedRelativeFile", () => {
|
||||
const target = {
|
||||
raw: "schema/x.md:12-14",
|
||||
path: "/Users/test/project/schema/x.md",
|
||||
lineStart: 12,
|
||||
lineEnd: 14,
|
||||
};
|
||||
|
||||
it("opens a nested-repo suffix when the workspace-root path is missing", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([
|
||||
{
|
||||
query: "schema/x.md",
|
||||
cwd: "/Users/test/project",
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
...target,
|
||||
path: "/Users/test/project/repo-a/schema/x.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the exact workspace path when suggestions return that relative path", async () => {
|
||||
const { getDirectorySuggestions } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(result).toEqual(target);
|
||||
});
|
||||
|
||||
it("keeps the original target when suggestions are empty", async () => {
|
||||
const { getDirectorySuggestions } = suggestionsFromMap({});
|
||||
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(target);
|
||||
});
|
||||
|
||||
it("keeps the original target when the daemon throws", async () => {
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions: unavailableSuggestions,
|
||||
}),
|
||||
).resolves.toEqual(target);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a bare filename", { ...target, path: "/Users/test/project/file.ts" }],
|
||||
["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }],
|
||||
["a home-relative path", { ...target, path: "~/schema/x.md" }],
|
||||
])("does not search %s", async (_label, candidate) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target: candidate,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([]);
|
||||
expect(result).toEqual(candidate);
|
||||
});
|
||||
|
||||
it("does not search when the workspace root is blank", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: " ",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([]);
|
||||
expect(result).toEqual(target);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/Users/test/project/schema/x.md", "/Users/test/project"],
|
||||
["file:///Users/test/project/schema/x.md", "/Users/test/project"],
|
||||
["C:/repo/schema/x.md", "C:/repo"],
|
||||
["\\\\server\\share\\schema\\x.md", "//server/share"],
|
||||
])("keeps absolute destination %s", async (raw, workspaceRoot) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` };
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target: absoluteTarget,
|
||||
workspaceRoot,
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(absoluteTarget);
|
||||
expect(searches).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["docs/guides", "./docs/guides", "docs/guides/"])(
|
||||
"keeps folder href %s direct",
|
||||
async (raw) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }],
|
||||
});
|
||||
const folderTarget = { raw, path: "/Users/test/project/docs/guides" };
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target: folderTarget,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(folderTarget);
|
||||
expect(searches).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("normalizes a Windows workspace root before the suffix lookup", async () => {
|
||||
const windowsTarget = {
|
||||
raw: "schema/x.md",
|
||||
path: "C:\\repo\\schema\\x.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
};
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target: windowsTarget,
|
||||
workspaceRoot: "C:\\repo\\",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([
|
||||
{
|
||||
query: "schema/x.md",
|
||||
cwd: "C:\\repo\\",
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(result.path).toBe("C:/repo/repo-a/schema/x.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantFileLinkToken", () => {
|
||||
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
||||
expect(
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
@@ -106,62 +105,6 @@ export async function fetchDaemonResolution({
|
||||
};
|
||||
}
|
||||
|
||||
/** Only file-looking paths nested inside the workspace need suffix search. */
|
||||
function nestedRelativeLookupQuery(
|
||||
targetPath: string,
|
||||
workspaceRoot: string | undefined,
|
||||
): string | null {
|
||||
const root = workspaceRoot?.trim();
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = targetPath.replace(/\\/g, "/");
|
||||
const prefix = `${normalizedRoot}/`;
|
||||
if (!normalizedPath.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const relative = normalizedPath.slice(prefix.length);
|
||||
if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) {
|
||||
return null;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
/** When a markdown link points at workspaceRoot/schema/x.md and that file is
|
||||
* missing, ask the same suffix lookup bare names already use. An exact hit
|
||||
* stays on that path. A suffix hit such as repo-a/schema/x.md replaces it.
|
||||
* Lookup failure keeps the original target.
|
||||
*/
|
||||
export async function resolveMissingNestedRelativeFile(input: {
|
||||
target: InlinePathTarget;
|
||||
workspaceRoot?: string;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}): Promise<InlinePathTarget> {
|
||||
if (
|
||||
isAbsoluteInlineCodeToken(input.target.raw) ||
|
||||
input.target.raw.startsWith("~/") ||
|
||||
input.target.raw.endsWith("/")
|
||||
) {
|
||||
return input.target;
|
||||
}
|
||||
const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot);
|
||||
if (!query) {
|
||||
return input.target;
|
||||
}
|
||||
try {
|
||||
return await fetchDaemonResolution({
|
||||
ambiguousQuery: query,
|
||||
token: input.target.raw,
|
||||
target: input.target,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
getDirectorySuggestions: input.getDirectorySuggestions,
|
||||
});
|
||||
} catch {
|
||||
return input.target;
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyForResolution(
|
||||
source: AssistantFileLinkSource,
|
||||
context: AssistantFileLinkContext,
|
||||
@@ -272,7 +215,11 @@ export function shouldResolveDirectFileThroughSuggestions(input: {
|
||||
}
|
||||
|
||||
function isAbsoluteInlineCodeToken(token: string): boolean {
|
||||
return isAbsolutePath(token) || token.toLowerCase().startsWith("file://");
|
||||
return (
|
||||
token.startsWith("/") ||
|
||||
token.toLowerCase().startsWith("file://") ||
|
||||
/^[A-Za-z]:[\\/]/.test(token)
|
||||
);
|
||||
}
|
||||
|
||||
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
||||
|
||||
+10
-11
@@ -1,10 +1,9 @@
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { isFdroidBuild } from "@/constants/build-profile";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
@@ -85,7 +84,7 @@ export function AddHostMethodModal({
|
||||
onClose={onClose}
|
||||
testID="add-host-method-modal"
|
||||
>
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleDirect}
|
||||
accessibilityRole="button"
|
||||
@@ -99,10 +98,10 @@ export function AddHostMethodModal({
|
||||
{t("pairing.connectionMethods.direct.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
|
||||
{isElectronRuntime() ? (
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleRemoteSsh}
|
||||
accessibilityRole="button"
|
||||
@@ -116,11 +115,11 @@ export function AddHostMethodModal({
|
||||
{t("pairing.connectionMethods.remoteSsh.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{isNative && !isFdroidBuild ? (
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleScan}
|
||||
accessibilityRole="button"
|
||||
@@ -133,10 +132,10 @@ export function AddHostMethodModal({
|
||||
{t("pairing.connectionMethods.scanQr.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handlePaste}
|
||||
accessibilityRole="button"
|
||||
@@ -150,7 +149,7 @@ export function AddHostMethodModal({
|
||||
{t("pairing.connectionMethods.pasteLink.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
+61
-50
@@ -1,22 +1,20 @@
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
|
||||
import { buildConnectionFailureCopy } from "@/components/connection-failure-copy";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
parseConnectionUri,
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PairingTargetTracker } from "@/components/pair-link-credentials";
|
||||
import { buildDirectConnectionUri } from "@/components/direct-connection-host";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
|
||||
@@ -141,11 +139,37 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function isIpv6Host(host: string): boolean {
|
||||
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
|
||||
}
|
||||
|
||||
function buildConnectionUriFromDraft(
|
||||
draft: DirectConnectionDraft,
|
||||
labels: DirectConnectionLabels,
|
||||
): string {
|
||||
const host = draft.host.trim();
|
||||
const port = Number(draft.port.trim());
|
||||
if (!host) {
|
||||
throw new Error(labels.hostRequired);
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(labels.invalidPort);
|
||||
}
|
||||
|
||||
return serializeConnectionUriForStorage({
|
||||
host,
|
||||
port,
|
||||
isIpv6: isIpv6Host(host),
|
||||
useTls: draft.useTls,
|
||||
...(draft.password ? { password: draft.password } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function prepareDirectConnection(
|
||||
draft: DirectConnectionDraft,
|
||||
labels: DirectConnectionLabels,
|
||||
): PreparedDirectConnection {
|
||||
const parsed = parseConnectionUri(buildDirectConnectionUri(draft, labels));
|
||||
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels));
|
||||
const endpoint = parsed.isIpv6
|
||||
? `[${parsed.host}]:${parsed.port}`
|
||||
: `${parsed.host}:${parsed.port}`;
|
||||
@@ -193,6 +217,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
}
|
||||
|
||||
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
|
||||
@@ -211,7 +236,10 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
const advancedTarget = useRef(new PairingTargetTracker("", true));
|
||||
|
||||
const connectIcon = useMemo(() => <ThemedLink2 size={16} />, []);
|
||||
const connectIcon = useMemo(
|
||||
() => <Link2 size={16} color={theme.colors.accentForeground} />,
|
||||
[theme.colors.accentForeground],
|
||||
);
|
||||
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
|
||||
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
|
||||
const checkboxStyle = useMemo(
|
||||
@@ -392,7 +420,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
advancedTarget.current = new PairingTargetTracker("", true);
|
||||
try {
|
||||
setAdvancedUri(
|
||||
buildDirectConnectionUri({ host, port, useTls, password }, directConnectionLabels),
|
||||
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
|
||||
);
|
||||
} catch {
|
||||
setAdvancedUri("");
|
||||
@@ -416,8 +444,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsAdvancedOpen(false);
|
||||
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
|
||||
|
||||
const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight;
|
||||
const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye;
|
||||
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
|
||||
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
@@ -431,7 +459,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
<View style={styles.portRow}>
|
||||
<View style={hostFieldStyle}>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
|
||||
<ThemedAdaptiveTextInput
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-input"
|
||||
nativeID="direct-host-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.host")}
|
||||
@@ -439,6 +467,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
resetKey={`direct-host-${inputResetKey}`}
|
||||
onChangeText={setHost}
|
||||
placeholder="localhost"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -449,7 +478,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
</View>
|
||||
<View style={portFieldStyle}>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
|
||||
<ThemedAdaptiveTextInput
|
||||
<AdaptiveTextInput
|
||||
testID="direct-port-input"
|
||||
nativeID="direct-port-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.port")}
|
||||
@@ -457,6 +486,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
resetKey={`direct-port-${inputResetKey}`}
|
||||
onChangeText={setPort}
|
||||
placeholder="6767"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -468,30 +498,29 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.checkboxRow}
|
||||
onPress={handleToggleUseTls}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={t("pairing.direct.fields.useSsl")}
|
||||
accessibilityState={useTlsAccessibilityState}
|
||||
aria-checked={useTls}
|
||||
testID="direct-ssl-toggle"
|
||||
>
|
||||
<View style={checkboxStyle}>
|
||||
{useTls ? (
|
||||
<View testID="direct-ssl-toggle-checked">
|
||||
<ThemedCheck size={14} />
|
||||
<Check size={14} color={theme.colors.accentForeground} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
|
||||
<View style={styles.passwordRow}>
|
||||
<ThemedAdaptiveTextInput
|
||||
<AdaptiveTextInput
|
||||
testID="direct-password-input"
|
||||
nativeID="direct-password-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.password")}
|
||||
@@ -499,6 +528,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
resetKey={`direct-password-${inputResetKey}`}
|
||||
onChangeText={setPassword}
|
||||
placeholder={t("pairing.direct.fields.optional")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={passwordInputStyle}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -507,7 +537,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
/>
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={handleTogglePasswordVisibility}
|
||||
disabled={isSaving}
|
||||
@@ -519,13 +549,13 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
}
|
||||
testID="direct-password-visibility-toggle"
|
||||
>
|
||||
<PasswordIcon size={18} />
|
||||
</PressHighlight>
|
||||
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.advancedToggle}
|
||||
onPress={handleToggleAdvanced}
|
||||
disabled={isSaving}
|
||||
@@ -535,11 +565,11 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
}
|
||||
testID="direct-host-advanced-toggle"
|
||||
>
|
||||
<AdvancedIcon size={16} />
|
||||
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
{isAdvancedOpen ? (
|
||||
<ThemedAdaptiveTextInput
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
@@ -547,6 +577,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
onChangeText={handleChangeAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -582,23 +613,3 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedLink2 = withUnistyles(Link2, (theme) => ({ color: theme.colors.accentForeground }));
|
||||
|
||||
const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.accentForeground }));
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
|
||||
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
|
||||
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
@@ -93,7 +92,7 @@ import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||
import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes";
|
||||
|
||||
interface AddProjectFlowProps {
|
||||
request: AddProjectFlowRequest;
|
||||
@@ -326,7 +325,6 @@ function setPageStatus(
|
||||
// The product flow is intentionally one cohesive page-stack state machine.
|
||||
// eslint-disable-next-line complexity
|
||||
export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
const addHost = useAddHost();
|
||||
const hosts = useHosts();
|
||||
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
|
||||
@@ -618,9 +616,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
icon: Plus,
|
||||
testID: "add-project-flow-add-host",
|
||||
select: () => {
|
||||
// The native project Modal must release its window before the root sheet opens.
|
||||
onClose();
|
||||
addHost();
|
||||
usePanelStore.getState().showMobileAgent();
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -719,7 +717,6 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
directoryPaths,
|
||||
githubQuery.data,
|
||||
host,
|
||||
addHost,
|
||||
onClose,
|
||||
openAddedProject,
|
||||
page,
|
||||
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const ThemedTerminal = withUnistyles(Terminal);
|
||||
@@ -174,11 +174,11 @@ export function AddRemoteSshHostModal({
|
||||
}
|
||||
|
||||
clear();
|
||||
onClose();
|
||||
onSaved?.({
|
||||
...result,
|
||||
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
|
||||
});
|
||||
onClose();
|
||||
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
|
||||
const handleTargetChange = useCallback((value: string) => {
|
||||
targetRef.current = value;
|
||||
@@ -260,7 +260,7 @@ export function AddRemoteSshHostModal({
|
||||
onSubmitEditing={handleSubmit}
|
||||
/>
|
||||
</View>
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={iconButtonStyle}
|
||||
onPress={handleTogglePasswordVisibility}
|
||||
disabled={isSaving}
|
||||
@@ -273,7 +273,7 @@ export function AddRemoteSshHostModal({
|
||||
testID="remote-ssh-password-visibility-toggle"
|
||||
>
|
||||
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Field>
|
||||
<View style={styles.actions}>
|
||||
@@ -4,9 +4,11 @@ import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { VolumeMeter } from "./volume-meter";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import type { DictationStatus } from "@/hooks/use-dictation";
|
||||
import type { DictationEmptyReason } from "@/dictation/result";
|
||||
|
||||
interface DictationControlsProps {
|
||||
volume: number;
|
||||
@@ -24,6 +26,30 @@ interface DictationControlsProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_REASON_MESSAGE_KEYS = {
|
||||
"no-audio": "message.dictation.empty.noAudio",
|
||||
"no-transcript": "message.dictation.empty.noTranscript",
|
||||
} as const satisfies Record<DictationEmptyReason, string>;
|
||||
|
||||
// The line under the meter once an attempt has finished without text.
|
||||
function resolveResultText(input: {
|
||||
t: TFunction;
|
||||
status: DictationStatus;
|
||||
errorText?: string;
|
||||
emptyReason?: DictationEmptyReason | null;
|
||||
}): string | null {
|
||||
const { t, status, errorText, emptyReason } = input;
|
||||
if (status === "empty") {
|
||||
return t(EMPTY_REASON_MESSAGE_KEYS[emptyReason ?? "no-transcript"]);
|
||||
}
|
||||
if (status !== "failed") {
|
||||
return null;
|
||||
}
|
||||
return errorText
|
||||
? t("message.dictation.failed", { error: errorText })
|
||||
: t("message.dictation.failedRetry");
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
@@ -147,18 +173,26 @@ export function DictationOverlay({
|
||||
isProcessing,
|
||||
status,
|
||||
errorText,
|
||||
emptyReason,
|
||||
onCancel,
|
||||
onAccept,
|
||||
onAcceptAndSend,
|
||||
onRetry,
|
||||
onDiscard,
|
||||
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
|
||||
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & {
|
||||
errorText?: string;
|
||||
emptyReason?: DictationEmptyReason | null;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isFailed = status === "failed";
|
||||
const isEmpty = status === "empty";
|
||||
// An empty result is a finished attempt the user has to acknowledge, like a failure.
|
||||
const isFailed = status === "failed" || isEmpty;
|
||||
const canRetry = !isEmpty || emptyReason !== "no-audio";
|
||||
const showActiveState = isRecording || isProcessing || isFailed;
|
||||
const actionsDisabled = isProcessing;
|
||||
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
|
||||
const resultText = resolveResultText({ t, status, errorText, emptyReason });
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [overlayStyles.container, { backgroundColor: theme.colors.accent }],
|
||||
@@ -202,26 +236,26 @@ export function DictationOverlay({
|
||||
</Pressable>
|
||||
|
||||
<View style={overlayStyles.centerContainer}>
|
||||
<View style={overlayStyles.meterRow}>
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={false}
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
color={theme.colors.accentForeground}
|
||||
/>
|
||||
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
|
||||
</View>
|
||||
{!resultText ? (
|
||||
<View style={overlayStyles.meterRow}>
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={false}
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
color={theme.colors.accentForeground}
|
||||
/>
|
||||
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{status === "paused" ? (
|
||||
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
|
||||
{t("message.dictation.paused")}
|
||||
</Text>
|
||||
) : null}
|
||||
{isFailed ? (
|
||||
{resultText ? (
|
||||
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
|
||||
{errorText
|
||||
? t("message.dictation.failed", { error: errorText })
|
||||
: t("message.dictation.failedRetry")}
|
||||
{resultText}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -232,7 +266,7 @@ export function DictationOverlay({
|
||||
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
|
||||
</View>
|
||||
) : null}
|
||||
{!actionsDisabled && isFailed ? (
|
||||
{!actionsDisabled && isFailed && canRetry ? (
|
||||
<Pressable
|
||||
onPress={onRetry}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDirectConnectionUri, normalizeDirectConnectionHost } from "./direct-connection-host";
|
||||
|
||||
const labels = {
|
||||
hostRequired: "host required",
|
||||
invalidPort: "invalid port",
|
||||
};
|
||||
|
||||
function buildUri(host: string, port = "6767"): string {
|
||||
return buildDirectConnectionUri({ host, port, useTls: false, password: "" }, labels);
|
||||
}
|
||||
|
||||
describe("normalizeDirectConnectionHost", () => {
|
||||
it.each([
|
||||
["100.66.69.36", { host: "100.66.69.36", port: null, isIpv6: false }],
|
||||
[" 100.66.69.36 ", { host: "100.66.69.36", port: null, isIpv6: false }],
|
||||
["100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }],
|
||||
["tcp://100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }],
|
||||
["http://100.66.69.36:6767/path?x=1#y", { host: "100.66.69.36", port: "6767", isIpv6: false }],
|
||||
["HTTPS://example.test", { host: "example.test", port: null, isIpv6: false }],
|
||||
["my-host.local:7000", { host: "my-host.local", port: "7000", isIpv6: false }],
|
||||
["[::1]", { host: "::1", port: null, isIpv6: true }],
|
||||
["[::1]:7000", { host: "::1", port: "7000", isIpv6: true }],
|
||||
["::1", { host: "::1", port: null, isIpv6: true }],
|
||||
["fe80::1", { host: "fe80::1", port: null, isIpv6: true }],
|
||||
["", { host: "", port: null, isIpv6: false }],
|
||||
])("normalizes %j", (input, expected) => {
|
||||
expect(normalizeDirectConnectionHost(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDirectConnectionUri", () => {
|
||||
it.each([
|
||||
["100.66.69.36", "tcp://100.66.69.36:6767"],
|
||||
["100.66.69.36:6767", "tcp://100.66.69.36:6767"],
|
||||
["tcp://100.66.69.36:6767", "tcp://100.66.69.36:6767"],
|
||||
["http://100.66.69.36:6767/path?x", "tcp://100.66.69.36:6767"],
|
||||
["my-host.local:6767", "tcp://my-host.local:6767"],
|
||||
["[::1]", "tcp://[::1]:6767"],
|
||||
["[::1]:7000", "tcp://[::1]:7000"],
|
||||
["::1", "tcp://[::1]:6767"],
|
||||
["fe80::1", "tcp://[fe80::1]:6767"],
|
||||
])("builds a tcp URI from host %j", (host, expected) => {
|
||||
expect(buildUri(host)).toBe(expected);
|
||||
});
|
||||
|
||||
it("lets a port typed in the host field override the port field", () => {
|
||||
expect(buildUri("100.66.69.36:7000", "6767")).toBe("tcp://100.66.69.36:7000");
|
||||
});
|
||||
|
||||
it("keeps TLS and password settings", () => {
|
||||
expect(
|
||||
buildDirectConnectionUri(
|
||||
{ host: "tcp://example.test:443", port: "6767", useTls: true, password: "secret" },
|
||||
labels,
|
||||
),
|
||||
).toBe("tcp://example.test:443?ssl=true&password=secret");
|
||||
});
|
||||
|
||||
it.each(["", " ", "tcp://", "http:///path"])("requires a host for %j", (host) => {
|
||||
expect(() => buildUri(host)).toThrow("host required");
|
||||
});
|
||||
|
||||
it("rejects an invalid port", () => {
|
||||
expect(() => buildUri("100.66.69.36", "70000")).toThrow("invalid port");
|
||||
expect(() => buildUri("100.66.69.36:0")).toThrow("invalid port");
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { serializeConnectionUriForStorage } from "@/utils/daemon-endpoints";
|
||||
|
||||
export interface DirectConnectionHostInput {
|
||||
host: string;
|
||||
port: string;
|
||||
useTls: boolean;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface DirectConnectionHostLabels {
|
||||
hostRequired: string;
|
||||
invalidPort: string;
|
||||
}
|
||||
|
||||
export interface NormalizedDirectConnectionHost {
|
||||
host: string;
|
||||
/** Port typed into the host field, if any. Overrides the separate port field. */
|
||||
port: string | null;
|
||||
isIpv6: boolean;
|
||||
}
|
||||
|
||||
const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
||||
const BRACKETED_IPV6_PATTERN = /^\[([^\]]*)\](?::(\d*))?$/;
|
||||
const HOST_WITH_PORT_PATTERN = /^([^:]+):(\d+)$/;
|
||||
|
||||
/**
|
||||
* Users often paste `host:port` or a full URL into the host field.
|
||||
* Reduce it to a bare host (plus an optional port) before building the URI.
|
||||
*/
|
||||
export function normalizeDirectConnectionHost(input: string): NormalizedDirectConnectionHost {
|
||||
let host = input.trim().replace(SCHEME_PATTERN, "");
|
||||
const suffixIndex = host.search(/[/?#]/);
|
||||
if (suffixIndex !== -1) {
|
||||
host = host.slice(0, suffixIndex);
|
||||
}
|
||||
|
||||
const bracketed = BRACKETED_IPV6_PATTERN.exec(host);
|
||||
if (bracketed) {
|
||||
return { host: bracketed[1] ?? "", port: bracketed[2] || null, isIpv6: true };
|
||||
}
|
||||
|
||||
const hostWithPort = HOST_WITH_PORT_PATTERN.exec(host);
|
||||
if (hostWithPort) {
|
||||
return { host: hostWithPort[1] ?? "", port: hostWithPort[2] ?? null, isIpv6: false };
|
||||
}
|
||||
|
||||
return { host, port: null, isIpv6: host.split(":").length > 2 };
|
||||
}
|
||||
|
||||
export function buildDirectConnectionUri(
|
||||
draft: DirectConnectionHostInput,
|
||||
labels: DirectConnectionHostLabels,
|
||||
): string {
|
||||
const normalized = normalizeDirectConnectionHost(draft.host);
|
||||
const port = Number((normalized.port ?? draft.port).trim());
|
||||
if (!normalized.host) {
|
||||
throw new Error(labels.hostRequired);
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(labels.invalidPort);
|
||||
}
|
||||
|
||||
return serializeConnectionUriForStorage({
|
||||
host: normalized.host,
|
||||
port,
|
||||
isIpv6: normalized.isIpv6,
|
||||
useTls: draft.useTls,
|
||||
...(draft.password ? { password: draft.password } : {}),
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
CircleGauge,
|
||||
@@ -63,7 +62,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
@@ -178,8 +177,14 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
router.push(buildSettingsRoute());
|
||||
}, []);
|
||||
|
||||
const handleAddHostMobile = useAddHost();
|
||||
const handleAddHostDesktop = handleAddHostMobile;
|
||||
const handleAddHostMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
}, [showMobileAgent]);
|
||||
|
||||
const handleAddHostDesktop = useCallback(() => {
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
}, []);
|
||||
|
||||
const handleOpenHostSettingsMobile = useCallback(
|
||||
(serverId: string) => {
|
||||
|
||||
@@ -35,15 +35,11 @@ export interface MarkdownImageContext {
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export function useMarkdownSource(
|
||||
text: string,
|
||||
context: MarkdownImageContext,
|
||||
commandNames?: ReadonlySet<string>,
|
||||
) {
|
||||
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
|
||||
const occurrenceKey = useId();
|
||||
const prepared = useMemo(
|
||||
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
|
||||
[text, context.workspaceRoot, commandNames],
|
||||
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }),
|
||||
[text, context.workspaceRoot],
|
||||
);
|
||||
const sources = [...new Set(prepared.images.map((image) => image.source))];
|
||||
const acquisitions = sources.map((source) => {
|
||||
|
||||
@@ -2,8 +2,6 @@ import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
|
||||
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
|
||||
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
|
||||
import { matchCommandLink } from "@/agent-commands";
|
||||
|
||||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||||
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
|
||||
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
|
||||
@@ -15,28 +13,6 @@ import { createMarkdownStyles } from "./styles";
|
||||
import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source";
|
||||
import { normalizeMarkdownHtml } from "./html";
|
||||
|
||||
it("recognizes complete known commands in inline code", () => {
|
||||
const names = new Set(["code-review", "review:fast"]);
|
||||
const prepared = prepareMarkdownSource("`/code-review` `/unknown`", undefined, {
|
||||
commandNames: names,
|
||||
});
|
||||
expect(prepared.linkRecognition.inlineCode.test("/code-review")).toBe(true);
|
||||
expect(prepared.linkRecognition.inlineCode.test("/unknown")).toBe(
|
||||
prepareMarkdownSource("`/unknown`").linkRecognition.inlineCode.test("/unknown"),
|
||||
);
|
||||
const nonPath = prepareMarkdownSource("`/review:fast`", undefined, { commandNames: names });
|
||||
expect(nonPath.linkRecognition.inlineCode.test("/review:fast")).toBe(true);
|
||||
expect(matchCommandLink("/code-review", "inline-code", names)).toBe("/code-review");
|
||||
});
|
||||
|
||||
it("resolves authored root-relative links only when the target is a known command", () => {
|
||||
const names = new Set(["code-review"]);
|
||||
expect(matchCommandLink("/code-review", "markdown", names)).toBe("/code-review");
|
||||
expect(matchCommandLink("/code-review/file", "markdown", names)).toBeNull();
|
||||
expect(matchCommandLink("/code-review", "text", names)).toBeNull();
|
||||
expect(matchCommandLink("https://example.com/code-review", "markdown", names)).toBeNull();
|
||||
});
|
||||
|
||||
describe("shared Markdown source adaptation", () => {
|
||||
it("keeps paragraphs, fences and quotes together for the library", () => {
|
||||
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
||||
@@ -130,186 +106,6 @@ it("preserves empty table cells while escaping spoilers in cell text", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a table directly after a paragraph through the library parser", async () => {
|
||||
const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
|
||||
const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
|
||||
const flags = { hardSoftBreaks: true, latexMath: false };
|
||||
const expected = await parseMarkdown(separated, flags);
|
||||
expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
|
||||
expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a quote",
|
||||
"> Notes\n> | Item |\n> | --- |\n> | A |",
|
||||
"> Notes\n>\n> | Item |\n> | --- |\n> | A |",
|
||||
["Document", ["Blockquote", "Paragraph", "Table"]],
|
||||
],
|
||||
[
|
||||
"a list item",
|
||||
"- Notes\n | Item |\n | --- |\n | A |\n- Next",
|
||||
"- Notes\n\n | Item |\n | --- |\n | A |\n- Next",
|
||||
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
|
||||
],
|
||||
[
|
||||
"a quoted list item",
|
||||
"> - Notes\n> | Item |\n> | --- |\n> | A |",
|
||||
"> - Notes\n>\n> | Item |\n> | --- |\n> | A |",
|
||||
["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]],
|
||||
],
|
||||
[
|
||||
"a quote inside a list item",
|
||||
"- > Notes\n > | Item |\n > | --- |\n > | A |",
|
||||
"- > Notes\n >\n > | Item |\n > | --- |\n > | A |",
|
||||
["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]],
|
||||
],
|
||||
[
|
||||
"aligned columns without outer pipes",
|
||||
"Notes\nItem | Detail\n:--- | ---:\nA | one",
|
||||
"Notes\n\nItem | Detail\n:--- | ---:\nA | one",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"an entity at the start of a header without outer pipes",
|
||||
"Notes\nAT&T | Detail\n--- | ---\nA | one",
|
||||
"Notes\n\nAT\\&T | Detail\n--- | ---\nA | one",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"a local image at the start of a header without outer pipes",
|
||||
"Notes\n | Detail\n--- | ---\nA | one",
|
||||
"Notes\n\nLocal | Detail\n--- | ---\nA | one",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"CRLF lines",
|
||||
"Notes\r\n| Item |\r\n| --- |\r\n| A |",
|
||||
"Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"CR lines",
|
||||
"Notes\r| Item |\r| --- |\r| A |",
|
||||
"Notes\r\r| Item |\r| --- |\r| A |",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"a quote with CRLF lines",
|
||||
"> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |",
|
||||
"> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |",
|
||||
["Document", ["Blockquote", "Paragraph", "Table"]],
|
||||
],
|
||||
[
|
||||
"a list item with CRLF lines",
|
||||
"- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
|
||||
"- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
|
||||
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
|
||||
],
|
||||
[
|
||||
"a quote with CR lines",
|
||||
"> Notes\r> | Item |\r> | --- |\r> | A |",
|
||||
"> Notes\r>\r> | Item |\r> | --- |\r> | A |",
|
||||
["Document", ["Blockquote", "Paragraph", "Table"]],
|
||||
],
|
||||
[
|
||||
"a list item with CR lines",
|
||||
"- Notes\r | Item |\r | --- |\r | A |\r- Next",
|
||||
"- Notes\r\r | Item |\r | --- |\r | A |\r- Next",
|
||||
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
|
||||
],
|
||||
[
|
||||
"a non-interrupting ordered marker with tildes",
|
||||
"Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |",
|
||||
"Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"a non-interrupting ordered marker with backticks",
|
||||
"Notes\n2. ```\n | Item |\n | --- |\n | A |",
|
||||
"Notes\n2. ```\n\n | Item |\n | --- |\n | A |",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => {
|
||||
const rendered = prepareMarkdownSource(source).render();
|
||||
expect(rendered).toEqual(separated);
|
||||
const flags = { hardSoftBreaks: true, latexMath: false };
|
||||
function blocks(node: ASTNode): unknown {
|
||||
if (node.type === "Paragraph" || node.type === "Table") return node.type;
|
||||
return [node.type, ...(node.children ?? []).map(blocks)];
|
||||
}
|
||||
expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a literal backslash",
|
||||
"Notes\\\n| Item |\n| --- |\n| A |",
|
||||
"Notes\\\n\n| Item |\n| --- |\n| A |",
|
||||
"Notes\\",
|
||||
],
|
||||
[
|
||||
"an entity and literal backslash with LF lines",
|
||||
"AT&T\\\n| Item |\n| --- |\n| A |",
|
||||
"AT\\&T\\\n\n| Item |\n| --- |\n| A |",
|
||||
"AT&T\\",
|
||||
],
|
||||
[
|
||||
"an entity and literal backslash with CRLF lines",
|
||||
"AT&T\\\r\n| Item |\r\n| --- |\r\n| A |",
|
||||
"AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |",
|
||||
"AT&T\\",
|
||||
],
|
||||
[
|
||||
"an entity and literal backslash with CR lines",
|
||||
"AT&T\\\r| Item |\r| --- |\r| A |",
|
||||
"AT\\&T\\\r\r| Item |\r| --- |\r| A |",
|
||||
"AT&T\\",
|
||||
],
|
||||
])(
|
||||
"preserves paragraph text before an interrupting table in %s",
|
||||
async (_name, source, separated, text) => {
|
||||
const rendered = prepareMarkdownSource(source).render();
|
||||
expect(rendered).toEqual(separated);
|
||||
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
|
||||
expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
|
||||
expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"```yaml\n---\nkey: value\n---\n```\n",
|
||||
"~~~md\n| Item |\n| --- |\n| A |\n~~~\n",
|
||||
"````md\n```\n| Item |\n| --- |\n```\n`````\n",
|
||||
"> ```md\n> | Item |\n> | --- |\n> ```\n",
|
||||
"> ```md\n> Code\n",
|
||||
"- ```md\n Code\n",
|
||||
" ```\n\n",
|
||||
"```not`a-fence\n",
|
||||
])("renders a table after a fence without changing its code: %s", async (prefix) => {
|
||||
const table = "| Item |\n| --- |\n| A |";
|
||||
const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render();
|
||||
expect(rendered).toEqual(`${prefix}Notes\n\n${table}`);
|
||||
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
|
||||
expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
|
||||
"Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
|
||||
"Notes\n\n| Item |\n| --- |\n| A |",
|
||||
"| Item |\n| --- |\n| A |",
|
||||
"```md\nNotes\n| Item |\n| --- |\n| A |\n```",
|
||||
"```yaml\n---\nkey: value\n---\n```",
|
||||
"```yaml\n---\nkey: value",
|
||||
"~~~md\n| Item |\n| --- |\n| A |\n~~~",
|
||||
"````md\n```\n| Item |\n| --- |\n```\n````",
|
||||
" Notes\n | Item |\n | --- |\n | A |",
|
||||
"Notes\n| Item | Detail |\n| --- |\n| A |",
|
||||
])("leaves separated tables and table-like text unchanged: %s", (source) => {
|
||||
expect(prepareMarkdownSource(source).render()).toEqual(source);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"src/components/message.tsx",
|
||||
"C:\\repo\\file.ts",
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useAssistantFileLinkEnvironment,
|
||||
} from "@/assistant-file-links";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -49,7 +48,6 @@ export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
selectable?: boolean;
|
||||
streaming?: boolean;
|
||||
onLinkPress?: (url: string) => unknown;
|
||||
agentCommands?: boolean;
|
||||
}
|
||||
|
||||
/** All Markdown surfaces share the same engine and source adaptation. */
|
||||
@@ -61,21 +59,15 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
|
||||
() => splitStreamingMarkdown(completeLines, true).head.length,
|
||||
[completeLines],
|
||||
);
|
||||
const Part = props.agentCommands ? CommandMarkdownPart : MarkdownPart;
|
||||
if (!streaming || boundary === 0) return <Part {...props} />;
|
||||
if (!streaming || boundary === 0) return <MarkdownPart {...props} />;
|
||||
return (
|
||||
<MarkdownBlockStack>
|
||||
<Part {...props} text={props.text.slice(0, boundary)} />
|
||||
<Part {...props} text={props.text.slice(boundary)} />
|
||||
<MarkdownPart {...props} text={props.text.slice(0, boundary)} />
|
||||
<MarkdownPart {...props} text={props.text.slice(boundary)} />
|
||||
</MarkdownBlockStack>
|
||||
);
|
||||
});
|
||||
|
||||
const CommandMarkdownPart = memo(function CommandMarkdownPart(props: MarkdownRendererProps) {
|
||||
const commandHints = useAgentCommandHints();
|
||||
return <MarkdownPart {...props} commandHints={commandHints} />;
|
||||
});
|
||||
|
||||
const MarkdownPart = memo(function MarkdownPart({
|
||||
text,
|
||||
compact = false,
|
||||
@@ -83,10 +75,8 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
selectable = true,
|
||||
streaming = false,
|
||||
onLinkPress,
|
||||
agentCommands: _agentCommands,
|
||||
commandHints = null,
|
||||
...imageContext
|
||||
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
|
||||
}: MarkdownRendererProps) {
|
||||
const environment = useAssistantFileLinkEnvironment();
|
||||
const actions = useAssistantFileLinkActions();
|
||||
const client =
|
||||
@@ -94,23 +84,14 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
(environment.client?.readFile
|
||||
? { readFile: environment.client.readFile.bind(environment.client) }
|
||||
: undefined);
|
||||
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(
|
||||
text,
|
||||
{
|
||||
workspaceRoot: environment.workspaceRoot,
|
||||
serverId: environment.serverId,
|
||||
...imageContext,
|
||||
client,
|
||||
},
|
||||
commandHints?.names,
|
||||
);
|
||||
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, {
|
||||
workspaceRoot: environment.workspaceRoot,
|
||||
serverId: environment.serverId,
|
||||
...imageContext,
|
||||
client,
|
||||
});
|
||||
const [image, setImage] = useState<ImageLightboxSource | null>(null);
|
||||
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
|
||||
const command = commandHints && matchCommandLink(url, sourceType, commandHints.names);
|
||||
if (command) {
|
||||
commandHints.insert(command);
|
||||
return;
|
||||
}
|
||||
const source = linkSource(url, sourceType);
|
||||
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
|
||||
else actions.open(source, "preferred");
|
||||
|
||||
@@ -56,11 +56,7 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
||||
};
|
||||
}
|
||||
|
||||
function collectInlineCodePaths(
|
||||
source: string,
|
||||
workspaceRoot?: string,
|
||||
commandNames?: ReadonlySet<string>,
|
||||
): ReadonlySet<string> {
|
||||
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> {
|
||||
const paths = new Set<string>();
|
||||
const previousEnds = new Map<number, number>();
|
||||
// Collect candidates between consecutive runs of equal length. The engine owns
|
||||
@@ -78,11 +74,7 @@ function collectInlineCodePaths(
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
const classification = classifyAssistantFileLink(value, { workspaceRoot });
|
||||
if (
|
||||
(classification && classification.kind !== "external") ||
|
||||
(value.startsWith("/") && commandNames?.has(value.slice(1)))
|
||||
)
|
||||
paths.add(value);
|
||||
if (classification && classification.kind !== "external") paths.add(value);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
@@ -120,7 +112,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
|
||||
export function prepareMarkdownSource(
|
||||
source: string,
|
||||
workspaceRoot?: string,
|
||||
options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
|
||||
options: { supportsSvg?: boolean } = {},
|
||||
) {
|
||||
source = normalizeMarkdownHtml(source);
|
||||
const images: MarkdownImage[] = [];
|
||||
@@ -130,18 +122,12 @@ export function prepareMarkdownSource(
|
||||
extensions: [gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
}));
|
||||
const metadata = linkMetadata(
|
||||
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
|
||||
);
|
||||
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
|
||||
const edits: { start: number; end: number; text: string }[] = [];
|
||||
if (
|
||||
!source.includes("![") &&
|
||||
!source.includes("||") &&
|
||||
!source.includes("&") &&
|
||||
// Match content, header, and delimiter lines; quote-only lines count as blank.
|
||||
!/^[\t >]*[^\t >\r\n][^\r\n]*(?:\r\n|\r|\n)[^\r\n]+(?:\r\n|\r|\n)(?=[\t >:|-]*\|)[\t >|]*:?-[-\t :|]*$/m.test(
|
||||
source,
|
||||
) &&
|
||||
!(workspaceRoot && source.includes("]("))
|
||||
) {
|
||||
return { images, ...metadata, render: () => source };
|
||||
@@ -216,25 +202,11 @@ export function prepareMarkdownSource(
|
||||
if (lineEnd < end) split(lineEnd);
|
||||
}
|
||||
}
|
||||
function visit(node: Nodes, previous?: Nodes) {
|
||||
const position = node.position;
|
||||
if (position?.start.offset !== undefined && position.end.offset !== undefined) {
|
||||
const start = position.start.offset;
|
||||
const end = position.end.offset;
|
||||
function visit(node: Nodes) {
|
||||
const start = node.position?.start.offset;
|
||||
const end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) {
|
||||
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
|
||||
if (
|
||||
node.type === "table" &&
|
||||
previous?.type === "paragraph" &&
|
||||
previous.position?.end.line === position.start.line - 1
|
||||
) {
|
||||
// MD4C only recognizes a table header on the first line of a paragraph.
|
||||
const lineStart =
|
||||
Math.max(source.lastIndexOf("\n", start - 1), source.lastIndexOf("\r", start - 1)) + 1;
|
||||
const newline = source.slice(lineStart - 2, lineStart).replace(/[^\r\n]/g, "");
|
||||
const blank = source.slice(lineStart, start).replace(/[^>]/g, " ").trimEnd();
|
||||
const boundary = lineStart - newline.length;
|
||||
edits.push({ start: boundary, end: lineStart, text: `${newline}${blank}${newline}` });
|
||||
}
|
||||
if (node.type === "link") {
|
||||
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
|
||||
if (title !== null)
|
||||
@@ -262,8 +234,7 @@ export function prepareMarkdownSource(
|
||||
if (text !== source.slice(start, end)) edits.push({ start, end, text });
|
||||
}
|
||||
}
|
||||
if ("children" in node)
|
||||
node.children.forEach((child, index) => visit(child, node.children[index - 1]));
|
||||
if ("children" in node) node.children.forEach(visit);
|
||||
}
|
||||
visit(getTree());
|
||||
return {
|
||||
|
||||
@@ -1199,7 +1199,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
>
|
||||
<MarkdownRenderer
|
||||
text={revealedMessage}
|
||||
agentCommands
|
||||
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
|
||||
client={client}
|
||||
workspaceRoot={workspaceRoot}
|
||||
|
||||
+11
-16
@@ -1,20 +1,16 @@
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Link } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
|
||||
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
|
||||
import { PairingTargetTracker } from "@/components/pair-link-credentials";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
|
||||
@@ -104,6 +100,7 @@ function PairLinkModalContent({
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: PairLinkModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { beginLinkPairing } = useHostMutations();
|
||||
@@ -129,7 +126,10 @@ function PairLinkModalContent({
|
||||
resetPasswordInput();
|
||||
}, []);
|
||||
|
||||
const pairIcon = useMemo(() => <ThemedLink size={16} />, []);
|
||||
const pairIcon = useMemo(
|
||||
() => <Link size={16} color={theme.colors.accentForeground} />,
|
||||
[theme.colors.accentForeground],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
@@ -212,7 +212,7 @@ function PairLinkModalContent({
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
||||
<ThemedAdaptiveTextInput
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
initialValue={initialUrl}
|
||||
testID="pair-link-input"
|
||||
@@ -220,6 +220,7 @@ function PairLinkModalContent({
|
||||
accessibilityLabel={t("pairing.link.label")}
|
||||
onChangeText={handleChangeOfferUrl}
|
||||
placeholder="https://app.paseo.sh/#offer=..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
@@ -234,7 +235,7 @@ function PairLinkModalContent({
|
||||
<Text style={styles.label}>
|
||||
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
|
||||
</Text>
|
||||
<ThemedAdaptiveTextInput
|
||||
<AdaptiveTextInput
|
||||
testID="pair-link-password-input"
|
||||
resetKey={`pair-link-password-${passwordResetKey}`}
|
||||
accessibilityLabel={t("pairing.hostPassword.label")}
|
||||
@@ -273,9 +274,3 @@ function PairLinkModalContent({
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground }));
|
||||
|
||||
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
@@ -39,10 +39,7 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function buildPermission(
|
||||
question: Record<string, unknown>,
|
||||
following: Record<string, unknown>[] = [],
|
||||
): PendingPermission {
|
||||
function buildPermission(question: Record<string, unknown>): PendingPermission {
|
||||
return {
|
||||
key: "perm-1",
|
||||
agentId: "agent-1",
|
||||
@@ -51,12 +48,12 @@ function buildPermission(
|
||||
provider: "claude",
|
||||
name: "AskUserQuestion",
|
||||
kind: "question",
|
||||
input: { questions: [question, ...following] },
|
||||
input: { questions: [question] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mountCard(question: Record<string, unknown>, following: Record<string, unknown>[] = []) {
|
||||
function mountCard(question: Record<string, unknown>) {
|
||||
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -64,7 +61,7 @@ function mountCard(question: Record<string, unknown>, following: Record<string,
|
||||
act(() =>
|
||||
root.render(
|
||||
<QuestionFormCard
|
||||
permission={buildPermission(question, following)}
|
||||
permission={buildPermission(question)}
|
||||
onRespond={onRespond}
|
||||
isResponding={false}
|
||||
responseError={null}
|
||||
@@ -74,14 +71,10 @@ function mountCard(question: Record<string, unknown>, following: Record<string,
|
||||
mounted.push({ root, container });
|
||||
|
||||
const view = within(container);
|
||||
const optionRole = question.multiSelect ? "checkbox" : "radio";
|
||||
const otherInput = () =>
|
||||
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
|
||||
const check = (label: string) =>
|
||||
act(() =>
|
||||
(
|
||||
view.queryByRole("radio", { name: label }) ?? view.getByRole("checkbox", { name: label })
|
||||
).click(),
|
||||
);
|
||||
const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
|
||||
const type = (text: string) => {
|
||||
const input = otherInput();
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
@@ -97,7 +90,7 @@ function mountCard(question: Record<string, unknown>, following: Record<string,
|
||||
if (!response || response.behavior !== "allow") throw new Error("card did not submit");
|
||||
return (response.updatedInput as { answers: Record<string, string> }).answers;
|
||||
};
|
||||
return { check, type, otherInput, submit, submittedAnswers, view, onRespond };
|
||||
return { check, type, otherInput, submit, submittedAnswers };
|
||||
}
|
||||
|
||||
const multiSelectQuestion = {
|
||||
@@ -161,31 +154,3 @@ describe("QuestionFormCard other answers", () => {
|
||||
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("QuestionFormCard explicit navigation", () => {
|
||||
it("keeps a picked option visible until Next and submits only on Submit", () => {
|
||||
const card = mountCard(singleSelectQuestion, [multiSelectQuestion]);
|
||||
card.check("Codex");
|
||||
expect(card.view.getByRole("radio", { name: "Codex" }).getAttribute("aria-checked")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(card.onRespond).not.toHaveBeenCalled();
|
||||
act(() => card.view.getByRole("button", { name: "Next" }).click());
|
||||
card.check("Apple");
|
||||
expect(card.onRespond).not.toHaveBeenCalled();
|
||||
card.submit();
|
||||
expect(card.submittedAnswers()).toEqual({ Provider: "Codex", Fruits: "Apple" });
|
||||
});
|
||||
|
||||
it("selects an option when its description is clicked without submitting", () => {
|
||||
const card = mountCard({
|
||||
...singleSelectQuestion,
|
||||
options: [{ label: "Codex", description: "Use this provider" }],
|
||||
});
|
||||
act(() => card.view.getByText("Use this provider").click());
|
||||
expect(card.view.getByRole("radio", { name: "Codex" }).getAttribute("aria-checked")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(card.onRespond).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,16 +43,20 @@ const IS_WEB = isWeb;
|
||||
// drag to the preview's nested scroll, so lines past a cap would be unreachable.
|
||||
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
|
||||
|
||||
// Links are independent actions. Ignore repeat clicks so double-click text selection
|
||||
// toggles an option only once.
|
||||
function isNonAnsweringClick(event: GestureResponderEvent): boolean {
|
||||
// Web clicks that must not answer: a link in the label, anything in the description, and the
|
||||
// second click of a double-click, which would otherwise land on the next question's options.
|
||||
function isNonAnsweringClick(event: GestureResponderEvent, description: View | null): boolean {
|
||||
if (!IS_WEB) return false;
|
||||
const click = event.nativeEvent as unknown as MouseEvent;
|
||||
if (click.detail > 1) return true;
|
||||
const target = click.target as Element | null;
|
||||
return Boolean(target?.closest("a"));
|
||||
const descriptionElement = description as unknown as Element | null;
|
||||
return Boolean(target?.closest("a") || descriptionElement?.contains(target));
|
||||
}
|
||||
|
||||
// The description is selectable text, so a touch on it belongs to the text, not the option.
|
||||
const claimResponder = () => true;
|
||||
|
||||
function getQuestionInputPlaceholder({
|
||||
question,
|
||||
answerPlaceholder,
|
||||
@@ -88,9 +92,10 @@ function QuestionOptionRow({
|
||||
}: QuestionOptionRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const descriptionRef = useRef<View>(null);
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
if (isNonAnsweringClick(event)) return;
|
||||
if (isNonAnsweringClick(event, descriptionRef.current)) return;
|
||||
onToggle(qIndex, optIndex, multiSelect);
|
||||
},
|
||||
[onToggle, qIndex, optIndex, multiSelect],
|
||||
@@ -147,7 +152,11 @@ function QuestionOptionRow({
|
||||
<View style={styles.optionTextBlock}>
|
||||
<MarkdownRenderer text={option.label} compact muted={!isSelected} selectable={false} />
|
||||
{option.description ? (
|
||||
<View testID="question-form-option-description">
|
||||
<View
|
||||
ref={descriptionRef}
|
||||
testID="question-form-option-description"
|
||||
onStartShouldSetResponder={claimResponder}
|
||||
>
|
||||
<MarkdownRenderer text={option.description} compact muted />
|
||||
</View>
|
||||
) : null}
|
||||
@@ -404,8 +413,12 @@ export function QuestionFormCard({
|
||||
});
|
||||
otherInputRef.current?.replaceText("");
|
||||
}
|
||||
|
||||
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
|
||||
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
|
||||
}
|
||||
},
|
||||
[otherTexts, selections],
|
||||
[activeQuestionIndex, otherTexts, questions, selections],
|
||||
);
|
||||
|
||||
const setOtherText = useCallback(
|
||||
|
||||
@@ -462,7 +462,7 @@ describe("sidebar group visibility", () => {
|
||||
|
||||
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
|
||||
const items = entries(Array(count).fill("done"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.current.canToggle).toBe(count > 7);
|
||||
});
|
||||
@@ -471,9 +471,7 @@ describe("sidebar group visibility", () => {
|
||||
"keeps row twelve visible for %s without filling gaps",
|
||||
(status) => {
|
||||
const items = entries([...Array(11).fill("done"), status]);
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, policy }),
|
||||
);
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-1",
|
||||
"row-2",
|
||||
@@ -494,11 +492,7 @@ describe("sidebar group visibility", () => {
|
||||
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
|
||||
for (const entriesByKey of [new Map(), hydrated]) {
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup({
|
||||
items,
|
||||
selection: null,
|
||||
policy: createWorkspaceVisibilityPolicy(entriesByKey),
|
||||
}),
|
||||
useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)),
|
||||
);
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
expect(names(result.current.visibleItems)).toEqual(
|
||||
@@ -518,7 +512,7 @@ describe("sidebar group visibility", () => {
|
||||
"needs_input",
|
||||
"failed",
|
||||
]);
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
});
|
||||
@@ -533,7 +527,7 @@ describe("sidebar group visibility", () => {
|
||||
"attention",
|
||||
]);
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
|
||||
({ rows }) => useLimitedSidebarGroup(rows, policy),
|
||||
{ initialProps: { rows: items } },
|
||||
);
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
@@ -574,7 +568,7 @@ describe("sidebar group visibility", () => {
|
||||
expect(result.current.expanded).toBe(false);
|
||||
act(() => result.current.toggleExpanded());
|
||||
unmount();
|
||||
const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
const remounted = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
expect(remounted.result.current.expanded).toBe(false);
|
||||
expect(remounted.result.current.visibleItems).toHaveLength(10);
|
||||
});
|
||||
@@ -598,12 +592,7 @@ describe("sidebar group visibility", () => {
|
||||
lifecycle: "active" as const,
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ last }) =>
|
||||
useLimitedSidebarGroup({
|
||||
items: [...items.slice(0, 11), last],
|
||||
selection: null,
|
||||
policy,
|
||||
}),
|
||||
({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy),
|
||||
{ initialProps: { last: items[11] } },
|
||||
);
|
||||
expect(result.current.visibleItems).toHaveLength(7);
|
||||
@@ -641,7 +630,7 @@ describe("sidebar group visibility", () => {
|
||||
|
||||
it("preserves the twenty-row pinned policy regardless of status", () => {
|
||||
const items = entries(Array(21).fill("running"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null }));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items));
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
act(() => result.current.toggleExpanded());
|
||||
|
||||
@@ -1598,16 +1598,11 @@ function ProjectBlock({
|
||||
[workspaceEntriesByKey],
|
||||
);
|
||||
const {
|
||||
leadingItem: leadingWorkspace,
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup({
|
||||
items: project.workspaces,
|
||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
||||
policy: visibilityPolicy,
|
||||
});
|
||||
} = useLimitedSidebarGroup(project.workspaces, visibilityPolicy);
|
||||
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
|
||||
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
|
||||
@@ -1766,33 +1761,28 @@ function ProjectBlock({
|
||||
let projectChildren = null;
|
||||
if (!collapsed) {
|
||||
if (project.workspaces.length > 0) {
|
||||
const leadingWorkspaceRow = leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null;
|
||||
projectChildren = (
|
||||
<>
|
||||
{manualSort ? (
|
||||
<>
|
||||
{leadingWorkspaceRow}
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
data={visibleWorkspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
</>
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
data={visibleWorkspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
style={styles.workspaceListContainer}
|
||||
>
|
||||
{leadingWorkspaceRow}
|
||||
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
|
||||
</View>
|
||||
)}
|
||||
@@ -2198,15 +2188,11 @@ function ProjectModeList({
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
||||
const {
|
||||
leadingItem: leadingPinnedChat,
|
||||
visibleItems: visiblePinnedChats,
|
||||
expanded: pinnedChatsExpanded,
|
||||
canToggle: canTogglePinnedChats,
|
||||
toggleExpanded: togglePinnedChatsExpanded,
|
||||
} = useLimitedSidebarGroup({
|
||||
items: pinnedChats,
|
||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
||||
});
|
||||
} = useLimitedSidebarGroup(pinnedChats);
|
||||
const nativeScrollGestureProps = useMemo(
|
||||
() =>
|
||||
parentGestureRef
|
||||
@@ -2409,38 +2395,37 @@ function ProjectModeList({
|
||||
[renderProjectBlock],
|
||||
);
|
||||
|
||||
const renderPinnedChatRow = useCallback(
|
||||
(
|
||||
workspace: SidebarWorkspacePlacement,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
) => (
|
||||
<MemoWorkspaceRowItem
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
|
||||
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
|
||||
leadingProjectName={workspace.projectName}
|
||||
leadingProjectIconDataUri={
|
||||
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
|
||||
}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={workspace.projectKind === "git"}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
),
|
||||
const renderPinnedChat = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
workspace={workspace}
|
||||
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
|
||||
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
|
||||
leadingProjectName={workspace.projectName}
|
||||
leadingProjectIconDataUri={
|
||||
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
|
||||
}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={workspace.projectKind === "git"}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={drag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
activeWorkspaceSelection,
|
||||
creatingWorkspaceIds,
|
||||
@@ -2456,22 +2441,6 @@ function ProjectModeList({
|
||||
],
|
||||
);
|
||||
|
||||
const renderPinnedChat = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return renderPinnedChatRow(workspace, {
|
||||
drag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps,
|
||||
});
|
||||
},
|
||||
[renderPinnedChatRow],
|
||||
);
|
||||
|
||||
const projectBody =
|
||||
projects.length === 0 ? (
|
||||
<SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} />
|
||||
@@ -2504,7 +2473,6 @@ function ProjectModeList({
|
||||
/>
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
{leadingPinnedChat ? renderPinnedChatRow(leadingPinnedChat) : null}
|
||||
<DraggableList
|
||||
testID="sidebar-pinned-list"
|
||||
data={visiblePinnedChats}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
import {
|
||||
selectSidebarGroupVisibility,
|
||||
type SidebarGroupItem,
|
||||
type SidebarGroupVisibilityPolicy,
|
||||
} from "./sidebar-group-visibility";
|
||||
|
||||
interface Item extends SidebarGroupItem {
|
||||
id: number;
|
||||
statusBucket: SidebarStateBucket;
|
||||
}
|
||||
|
||||
const serverId = "host-a";
|
||||
|
||||
function rows(statuses: readonly SidebarStateBucket[], rowServerId = serverId): Item[] {
|
||||
return statuses.map((statusBucket, index) => ({
|
||||
id: index + 1,
|
||||
serverId: rowServerId,
|
||||
workspaceId: `workspace-${index + 1}`,
|
||||
statusBucket,
|
||||
}));
|
||||
}
|
||||
|
||||
const workspacePolicy: SidebarGroupVisibilityPolicy<Item> = {
|
||||
kind: "workspaces",
|
||||
getStatusBucket: (item) => item.statusBucket,
|
||||
};
|
||||
|
||||
function selectionFor(item: SidebarGroupItem): ActiveWorkspaceSelection {
|
||||
return { serverId: item.serverId, workspaceId: item.workspaceId };
|
||||
}
|
||||
|
||||
describe("selectSidebarGroupVisibility", () => {
|
||||
it("leads with a hidden open row while keeping the baseline order", () => {
|
||||
const items = rows(Array(9).fill("done"));
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: false,
|
||||
policy: workspacePolicy,
|
||||
selection: selectionFor(items[8]!),
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBe(items[8]);
|
||||
expect(result.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.canToggle).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves selected rows in place when they are already in the baseline", () => {
|
||||
const items = rows([...Array(8).fill("done"), "running", "done"]);
|
||||
const expected = [...items.slice(0, 7), items[8]];
|
||||
|
||||
for (const selectedIndex of [2, 8]) {
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: false,
|
||||
policy: workspacePolicy,
|
||||
selection: selectionFor(items[selectedIndex]!),
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBeNull();
|
||||
expect(result.visibleItems).toEqual(expected);
|
||||
expect(result.canToggle).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the whole group without a toggle when the open row is the only hidden row", () => {
|
||||
const items = rows(Array(8).fill("done"));
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: false,
|
||||
policy: workspacePolicy,
|
||||
selection: selectionFor(items[7]!),
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBeNull();
|
||||
expect(result.visibleItems).toEqual(items);
|
||||
expect(result.canToggle).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps expanded groups in their original order", () => {
|
||||
const items = rows(Array(9).fill("done"));
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: true,
|
||||
policy: workspacePolicy,
|
||||
selection: selectionFor(items[8]!),
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBeNull();
|
||||
expect(result.visibleItems).toEqual(items);
|
||||
expect(result.canToggle).toBe(true);
|
||||
});
|
||||
|
||||
it("applies the twenty-row limit to pinned groups", () => {
|
||||
const items = rows(Array(22).fill("done"));
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: false,
|
||||
policy: { kind: "pinned" },
|
||||
selection: selectionFor(items[21]!),
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBe(items[21]);
|
||||
expect(result.visibleItems).toEqual(items.slice(0, 20));
|
||||
expect(result.canToggle).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the current limit when selection is null", () => {
|
||||
const items = rows(Array(8).fill("done"));
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: false,
|
||||
policy: workspacePolicy,
|
||||
selection: null,
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBeNull();
|
||||
expect(result.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.canToggle).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the open row by server and workspace identity", () => {
|
||||
const items = rows(Array(9).fill("done"), "host-b");
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: false,
|
||||
policy: workspacePolicy,
|
||||
selection: { serverId: serverId, workspaceId: items[8]!.workspaceId },
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBeNull();
|
||||
expect(result.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.canToggle).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,4 @@
|
||||
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
|
||||
export interface SidebarGroupItem {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface SidebarGroupVisibilityInput<T> {
|
||||
items: readonly T[];
|
||||
expanded: boolean;
|
||||
policy: SidebarGroupVisibilityPolicy<T>;
|
||||
/** The workspace the group's rows highlight as selected. Null when no row can be selected. */
|
||||
selection: ActiveWorkspaceSelection | null;
|
||||
}
|
||||
|
||||
export interface SidebarGroupVisibility<T> {
|
||||
/**
|
||||
* The open workspace when the collapsed limit would hide it. Rendered before `visibleItems`,
|
||||
* out of group order, outside any drag list. Never also present in `visibleItems`.
|
||||
*/
|
||||
leadingItem: T | null;
|
||||
visibleItems: T[];
|
||||
canToggle: boolean;
|
||||
}
|
||||
|
||||
export type SidebarGroupVisibilityPolicy<T> =
|
||||
| { kind: "pinned" }
|
||||
@@ -40,45 +16,18 @@ export function createWorkspaceVisibilityPolicy<T extends { workspaceKey: string
|
||||
};
|
||||
}
|
||||
|
||||
function isOpenWorkspace(
|
||||
item: SidebarGroupItem,
|
||||
selection: ActiveWorkspaceSelection | null,
|
||||
): boolean {
|
||||
return (
|
||||
selection !== null &&
|
||||
item.serverId === selection.serverId &&
|
||||
item.workspaceId === selection.workspaceId
|
||||
);
|
||||
}
|
||||
|
||||
export function selectSidebarGroupVisibility<T extends SidebarGroupItem>(
|
||||
input: SidebarGroupVisibilityInput<T>,
|
||||
): SidebarGroupVisibility<T> {
|
||||
const { items, expanded, policy, selection } = input;
|
||||
export function selectSidebarGroupVisibility<T>(
|
||||
items: readonly T[],
|
||||
expanded: boolean,
|
||||
policy: SidebarGroupVisibilityPolicy<T>,
|
||||
) {
|
||||
const limit = policy.kind === "pinned" ? 20 : 7;
|
||||
const baseline: T[] = [];
|
||||
let leadingItem: T | null = null;
|
||||
let canToggle = false;
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const inBaseline =
|
||||
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done");
|
||||
if (inBaseline) {
|
||||
baseline.push(item);
|
||||
} else if (leadingItem === null && isOpenWorkspace(item, selection)) {
|
||||
leadingItem = item;
|
||||
} else {
|
||||
canToggle = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (expanded || !canToggle) {
|
||||
return { leadingItem: null, visibleItems: items.slice(), canToggle };
|
||||
}
|
||||
|
||||
const baseline = items.filter(
|
||||
(item, index) =>
|
||||
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"),
|
||||
);
|
||||
return {
|
||||
leadingItem,
|
||||
visibleItems: baseline,
|
||||
canToggle,
|
||||
visibleItems: expanded ? items.slice() : baseline,
|
||||
canToggle: baseline.length < items.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,7 +147,6 @@ export function SidebarStatusWorkspaceList({
|
||||
dragGestureHostActive,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const { t } = useTranslation();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedWorkspaceGroupKeys,
|
||||
);
|
||||
@@ -156,27 +155,23 @@ export function SidebarStatusWorkspaceList({
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
const {
|
||||
leadingItem: leadingPinnedWorkspace,
|
||||
visibleItems: visiblePinnedWorkspaces,
|
||||
expanded: pinnedWorkspacesExpanded,
|
||||
canToggle: canTogglePinnedWorkspaces,
|
||||
toggleExpanded: togglePinnedWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
|
||||
} = useLimitedSidebarGroup(pinnedWorkspaces);
|
||||
|
||||
const statusShortcutIndex = showShortcutBadges
|
||||
? shortcutIndexByWorkspaceKey
|
||||
: EMPTY_SHORTCUT_INDEX;
|
||||
const renderPinnedWorkspaceRow = useCallback(
|
||||
(
|
||||
workspace: SidebarWorkspaceEntry,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
) => (
|
||||
const renderPinnedWorkspace = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
{...buildStatusRowProjectPresentation({
|
||||
workspace,
|
||||
@@ -189,9 +184,9 @@ export function SidebarStatusWorkspaceList({
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
drag={drag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -204,20 +199,6 @@ export function SidebarStatusWorkspaceList({
|
||||
supportsPinningByServerId,
|
||||
],
|
||||
);
|
||||
const renderPinnedWorkspace = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) =>
|
||||
renderPinnedWorkspaceRow(workspace, {
|
||||
drag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps,
|
||||
}),
|
||||
[renderPinnedWorkspaceRow],
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
{pinnedWorkspaces.length > 0 ? (
|
||||
@@ -230,7 +211,6 @@ export function SidebarStatusWorkspaceList({
|
||||
/>
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
{leadingPinnedWorkspace ? renderPinnedWorkspaceRow(leadingPinnedWorkspace) : null}
|
||||
<DraggableList
|
||||
testID="sidebar-pinned-list"
|
||||
data={visiblePinnedWorkspaces}
|
||||
@@ -365,37 +345,12 @@ function StatusGroupRows({
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const {
|
||||
leadingItem: leadingWorkspace,
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup({
|
||||
items: group.rows,
|
||||
selection: activeWorkspaceSelection,
|
||||
policy: STATUS_GROUP_VISIBILITY,
|
||||
});
|
||||
|
||||
function renderWorkspaceRow(workspace: SidebarWorkspaceEntry) {
|
||||
return (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
{...buildStatusRowProjectPresentation({
|
||||
workspace,
|
||||
projectIconByProjectViewKey,
|
||||
hostBadgeByServerId,
|
||||
})}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY);
|
||||
|
||||
return (
|
||||
<View style={collapsed ? undefined : styles.statusGroupBlockExpanded}>
|
||||
@@ -405,8 +360,22 @@ function StatusGroupRows({
|
||||
style={styles.statusWorkspaceListContainer}
|
||||
testID={`sidebar-status-group-rows-${group.key}`}
|
||||
>
|
||||
{leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null}
|
||||
{visibleWorkspaces.map(renderWorkspaceRow)}
|
||||
{visibleWorkspaces.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
{...buildStatusRowProjectPresentation({
|
||||
workspace,
|
||||
projectIconByProjectViewKey,
|
||||
hostBadgeByServerId,
|
||||
})}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
selectSidebarGroupVisibility,
|
||||
type SidebarGroupItem,
|
||||
type SidebarGroupVisibility,
|
||||
type SidebarGroupVisibilityPolicy,
|
||||
} from "./sidebar-group-visibility";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
|
||||
const PINNED_POLICY = { kind: "pinned" } as const;
|
||||
|
||||
interface LimitedSidebarGroupInput<T> {
|
||||
items: readonly T[];
|
||||
selection: ActiveWorkspaceSelection | null;
|
||||
policy?: SidebarGroupVisibilityPolicy<T>;
|
||||
}
|
||||
|
||||
export function useLimitedSidebarGroup<T extends SidebarGroupItem>({
|
||||
items,
|
||||
selection,
|
||||
policy = PINNED_POLICY,
|
||||
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
|
||||
expanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
} {
|
||||
export function useLimitedSidebarGroup<T>(
|
||||
items: readonly T[],
|
||||
policy: SidebarGroupVisibilityPolicy<T> = PINNED_POLICY,
|
||||
) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibility = useMemo(
|
||||
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
|
||||
[expanded, items, policy, selection],
|
||||
() => selectSidebarGroupVisibility(items, expanded, policy),
|
||||
[expanded, items, policy],
|
||||
);
|
||||
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
} from "../terminal/local-links/terminal-local-link-provider";
|
||||
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
|
||||
import type { TerminalFile } from "../terminal/file-paste";
|
||||
import type { TerminalCharacterWidths } from "../terminal/character-widths";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
|
||||
@@ -33,7 +32,6 @@ export interface TerminalEmulatorProps {
|
||||
ref: Ref<TerminalEmulatorHandle>;
|
||||
streamKey: string;
|
||||
supportsTerminalInputModeReplay: boolean;
|
||||
characterWidths: TerminalCharacterWidths;
|
||||
testId?: string;
|
||||
xtermTheme?: ITheme;
|
||||
scrollbackLines: number;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Keyboard,
|
||||
type LayoutChangeEvent,
|
||||
PanResponder,
|
||||
type PanResponderGestureState,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
@@ -46,10 +47,13 @@ import {
|
||||
type TerminalSelectionViewport,
|
||||
} from "../terminal/native-renderer/terminal-selection";
|
||||
import {
|
||||
createTerminalSelectionController,
|
||||
terminalSelectionHandles,
|
||||
TERMINAL_SELECTION_GRIP_SIZE,
|
||||
} from "../terminal/native-renderer/terminal-selection-controller";
|
||||
TERMINAL_GESTURE_LONG_PRESS_MS,
|
||||
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
|
||||
advanceTerminalScrollGesture,
|
||||
classifyTerminalGestureIntent,
|
||||
resolveTerminalGestureReleaseAction,
|
||||
type TerminalGestureIntent,
|
||||
} from "../terminal/native-renderer/terminal-selection-gesture";
|
||||
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
|
||||
import {
|
||||
forwardNativeTerminalKey,
|
||||
@@ -83,6 +87,38 @@ const DEFAULT_XTERM_THEME: ITheme = {
|
||||
foreground: "#e6e6e6",
|
||||
cursor: "#e6e6e6",
|
||||
};
|
||||
interface NativeTerminalPanState {
|
||||
lastDy: number;
|
||||
rowRemainder: number;
|
||||
didScroll: boolean;
|
||||
didNavigate: boolean;
|
||||
movedBeyondTapTolerance: boolean;
|
||||
intent: TerminalGestureIntent | null;
|
||||
}
|
||||
|
||||
interface NativeTerminalSelectionPress {
|
||||
startX: number;
|
||||
startY: number;
|
||||
startedAt: number;
|
||||
startedWithSelection: boolean;
|
||||
status: "pressing" | "selecting";
|
||||
}
|
||||
|
||||
function resolvePanResponderPoint(
|
||||
press: NativeTerminalSelectionPress,
|
||||
event: GestureResponderEvent,
|
||||
gesture: PanResponderGestureState,
|
||||
): { x: number; y: number } {
|
||||
const eventDx = event.nativeEvent.locationX - press.startX;
|
||||
const eventDy = event.nativeEvent.locationY - press.startY;
|
||||
const dx = Math.abs(eventDx) > Math.abs(gesture.dx) ? eventDx : gesture.dx;
|
||||
const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy;
|
||||
return {
|
||||
x: press.startX + dx,
|
||||
y: press.startY + dy,
|
||||
};
|
||||
}
|
||||
|
||||
function initialNativeTerminalSize(): TerminalMeasuredSize {
|
||||
return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
|
||||
}
|
||||
@@ -93,7 +129,6 @@ function NativeTerminalEmulator({
|
||||
testId = "terminal-surface",
|
||||
xtermTheme = DEFAULT_XTERM_THEME,
|
||||
scrollbackLines,
|
||||
characterWidths,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
keyboardInset = 0,
|
||||
@@ -128,14 +163,19 @@ function NativeTerminalEmulator({
|
||||
const outputDrainRef = useRef<NativeTerminalOutputDrain | null>(null);
|
||||
const screenModelRef = useRef<TerminalScreenModel | null>(null);
|
||||
const selectionModelRef = useRef<TerminalSelectionModel>(createTerminalSelectionModel());
|
||||
const selectionControllerRef = useRef<ReturnType<
|
||||
typeof createTerminalSelectionController
|
||||
> | null>(null);
|
||||
const gestureStartRef = useRef({ x: 0, y: 0 });
|
||||
const [cellMetrics, setCellMetrics] = useState<TerminalGridCellMetrics | null>(null);
|
||||
const selectionLongPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selectionPressRef = useRef<NativeTerminalSelectionPress | null>(null);
|
||||
const latestResolvedScreenRef = useRef<TerminalScreenState | null>(null);
|
||||
const paintFrameRef = useRef<number | null>(null);
|
||||
const outputDecoderRef = useRef(new TextDecoder());
|
||||
const panStateRef = useRef<NativeTerminalPanState>({
|
||||
lastDy: 0,
|
||||
rowRemainder: 0,
|
||||
didScroll: false,
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
});
|
||||
const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
|
||||
const lastInputModeStateRef = useRef<TerminalInputModeState>({
|
||||
...inputModeTrackerRef.current.getState(),
|
||||
@@ -205,7 +245,6 @@ function NativeTerminalEmulator({
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
selectionControllerRef.current?.cancel();
|
||||
const snapshot = selectionModelRef.current.clear();
|
||||
commitSelectionRange(snapshot.range);
|
||||
}, [commitSelectionRange]);
|
||||
@@ -216,14 +255,20 @@ function NativeTerminalEmulator({
|
||||
|
||||
const syncSelectionWithTerminal = useCallback(
|
||||
(terminal: NativeHeadlessTerminal) => {
|
||||
const previous = selectionModelRef.current.getSnapshot().range;
|
||||
const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() });
|
||||
if (previous && !snapshot.range) selectionControllerRef.current?.cancel();
|
||||
commitSelectionRange(snapshot.range);
|
||||
},
|
||||
[commitSelectionRange],
|
||||
);
|
||||
|
||||
const clearSelectionLongPressTimeout = useCallback(() => {
|
||||
if (selectionLongPressTimeoutRef.current === null) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(selectionLongPressTimeoutRef.current);
|
||||
selectionLongPressTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => {
|
||||
return (
|
||||
resolveMeasuredNativeTerminalSize({
|
||||
@@ -374,7 +419,6 @@ function NativeTerminalEmulator({
|
||||
rows: size.rows,
|
||||
cols: size.cols,
|
||||
scrollbackLines,
|
||||
characterWidths,
|
||||
});
|
||||
terminalRef.current = terminal;
|
||||
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
|
||||
@@ -389,7 +433,6 @@ function NativeTerminalEmulator({
|
||||
resetInputModeTracker,
|
||||
resolveTerminalScreen,
|
||||
scrollbackLines,
|
||||
characterWidths,
|
||||
clearSelection,
|
||||
]);
|
||||
|
||||
@@ -480,7 +523,8 @@ function NativeTerminalEmulator({
|
||||
emitMeasuredSize({ source: "measure" });
|
||||
return () => {
|
||||
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
|
||||
selectionControllerRef.current?.cancel();
|
||||
clearSelectionLongPressTimeout();
|
||||
selectionPressRef.current = null;
|
||||
if (paintFrameRef.current !== null) {
|
||||
cancelAnimationFrame(paintFrameRef.current);
|
||||
paintFrameRef.current = null;
|
||||
@@ -490,7 +534,7 @@ function NativeTerminalEmulator({
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
};
|
||||
}, [emitMeasuredSize, resetTerminal, streamKey]);
|
||||
}, [clearSelectionLongPressTimeout, emitMeasuredSize, resetTerminal, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) return;
|
||||
@@ -547,7 +591,6 @@ function NativeTerminalEmulator({
|
||||
const handleCellMetricsChange = useCallback(
|
||||
(metrics: TerminalGridCellMetrics) => {
|
||||
metricsRef.current = metrics;
|
||||
setCellMetrics(metrics);
|
||||
emitMeasuredSize({ source: "measure" });
|
||||
},
|
||||
[emitMeasuredSize],
|
||||
@@ -620,6 +663,14 @@ function NativeTerminalEmulator({
|
||||
if (!coordinate) {
|
||||
return;
|
||||
}
|
||||
const existingPress = selectionPressRef.current;
|
||||
selectionPressRef.current = {
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
startedAt: existingPress?.startedAt ?? Date.now(),
|
||||
startedWithSelection: existingPress?.startedWithSelection ?? false,
|
||||
status: "selecting",
|
||||
};
|
||||
const bounds = terminal.getBufferBounds();
|
||||
const wordSelection = resolveTerminalWordSelection({ terminal, coordinate });
|
||||
let snapshot = selectionModelRef.current.begin({
|
||||
@@ -634,61 +685,222 @@ function NativeTerminalEmulator({
|
||||
[commitSelectionRange, resolveSelectionCoordinate],
|
||||
);
|
||||
|
||||
const selectionController = useMemo(
|
||||
() =>
|
||||
createTerminalSelectionController({
|
||||
getGeometry: () => {
|
||||
const viewport = resolveSelectionViewport();
|
||||
const metrics = metricsRef.current;
|
||||
return viewport && metrics
|
||||
? { range: selectionRangeRef.current, viewport, metrics }
|
||||
: null;
|
||||
},
|
||||
onBeginHandle: (endpoint, cols) =>
|
||||
selectionModelRef.current.beginEndpointDrag(endpoint, cols),
|
||||
onMoveHandle: (coordinate) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
commitSelectionRange(
|
||||
selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() })
|
||||
.range,
|
||||
);
|
||||
},
|
||||
onLongPress: beginSelectionAtPoint,
|
||||
onTap: focusTerminalFromTap,
|
||||
onScroll: applyScroll,
|
||||
schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number,
|
||||
cancel: (timer) => clearTimeout(timer),
|
||||
}),
|
||||
[
|
||||
applyScroll,
|
||||
beginSelectionAtPoint,
|
||||
commitSelectionRange,
|
||||
focusTerminalFromTap,
|
||||
resolveSelectionViewport,
|
||||
],
|
||||
const updateSelectionAtPoint = useCallback(
|
||||
(point: { x: number; y: number }) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal || selectionPressRef.current?.status !== "selecting") {
|
||||
return;
|
||||
}
|
||||
const coordinate = resolveSelectionCoordinate(point);
|
||||
if (!coordinate) {
|
||||
return;
|
||||
}
|
||||
const snapshot = selectionModelRef.current.update({
|
||||
coordinate,
|
||||
bounds: terminal.getBufferBounds(),
|
||||
});
|
||||
commitSelectionRange(snapshot.range);
|
||||
},
|
||||
[commitSelectionRange, resolveSelectionCoordinate],
|
||||
);
|
||||
|
||||
const handlePanResponderScroll = useCallback(
|
||||
(currentDy: number) => {
|
||||
const cellHeight = metricsRef.current?.cellHeight;
|
||||
if (!cellHeight || cellHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
const panState = panStateRef.current;
|
||||
const update = advanceTerminalScrollGesture({
|
||||
state: panState,
|
||||
currentDy,
|
||||
cellHeight,
|
||||
});
|
||||
panState.lastDy = update.state.lastDy;
|
||||
panState.rowRemainder = update.state.rowRemainder;
|
||||
panState.didScroll = update.state.didScroll;
|
||||
const { rowDelta } = update;
|
||||
if (rowDelta === 0) {
|
||||
return;
|
||||
}
|
||||
if (rowDelta > 0) {
|
||||
applyScroll("up", rowDelta);
|
||||
return;
|
||||
}
|
||||
applyScroll("down", Math.abs(rowDelta));
|
||||
},
|
||||
[applyScroll],
|
||||
);
|
||||
selectionControllerRef.current = selectionController;
|
||||
|
||||
const panResponder = useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onStartShouldSetPanResponderCapture: () => true,
|
||||
onMoveShouldSetPanResponder: (_event, gesture) => {
|
||||
const intent = classifyTerminalGestureIntent({
|
||||
status: selectionRangeRef.current ? "selecting" : "pressing",
|
||||
dx: gesture.dx,
|
||||
dy: gesture.dy,
|
||||
vx: gesture.vx,
|
||||
vy: gesture.vy,
|
||||
});
|
||||
return intent === "scroll" || intent === "select";
|
||||
},
|
||||
onMoveShouldSetPanResponderCapture: (_event, gesture) => {
|
||||
const intent = classifyTerminalGestureIntent({
|
||||
status: selectionRangeRef.current ? "selecting" : "pressing",
|
||||
dx: gesture.dx,
|
||||
dy: gesture.dy,
|
||||
vx: gesture.vx,
|
||||
vy: gesture.vy,
|
||||
});
|
||||
return intent === "scroll" || intent === "select";
|
||||
},
|
||||
onPanResponderGrant: (event) => {
|
||||
const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY };
|
||||
gestureStartRef.current = point;
|
||||
selectionController.press(point);
|
||||
panStateRef.current = {
|
||||
lastDy: 0,
|
||||
rowRemainder: 0,
|
||||
didScroll: false,
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
};
|
||||
const point = {
|
||||
x: event.nativeEvent.locationX,
|
||||
y: event.nativeEvent.locationY,
|
||||
};
|
||||
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
|
||||
selectionPressRef.current = {
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
startedAt: Date.now(),
|
||||
startedWithSelection: pressStatus === "selecting",
|
||||
status: pressStatus,
|
||||
};
|
||||
clearSelectionLongPressTimeout();
|
||||
if (pressStatus === "selecting") {
|
||||
return;
|
||||
}
|
||||
selectionLongPressTimeoutRef.current = setTimeout(() => {
|
||||
selectionLongPressTimeoutRef.current = null;
|
||||
const press = selectionPressRef.current;
|
||||
if (press?.status !== "pressing") {
|
||||
return;
|
||||
}
|
||||
beginSelectionAtPoint({ x: press.startX, y: press.startY });
|
||||
}, TERMINAL_GESTURE_LONG_PRESS_MS);
|
||||
},
|
||||
onPanResponderMove: (event, gesture) => {
|
||||
const selectionPress = selectionPressRef.current;
|
||||
const panState = panStateRef.current;
|
||||
if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
|
||||
panState.movedBeyondTapTolerance = true;
|
||||
}
|
||||
if (selectionPress?.status === "selecting") {
|
||||
if (panState.movedBeyondTapTolerance) {
|
||||
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (panState.intent === "scroll") {
|
||||
handlePanResponderScroll(gesture.dy);
|
||||
return;
|
||||
}
|
||||
if (panState.didNavigate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionPress?.status === "pressing") {
|
||||
const point = resolvePanResponderPoint(selectionPress, event, gesture);
|
||||
const dx = point.x - selectionPress.startX;
|
||||
const dy = point.y - selectionPress.startY;
|
||||
const intent = classifyTerminalGestureIntent({
|
||||
status: "pressing",
|
||||
dx,
|
||||
dy,
|
||||
vx: gesture.vx,
|
||||
vy: gesture.vy,
|
||||
elapsedMs: Date.now() - selectionPress.startedAt,
|
||||
});
|
||||
if (intent === "tap" || intent === "pending") {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelectionLongPressTimeout();
|
||||
panState.intent = intent;
|
||||
|
||||
if (intent === "scroll") {
|
||||
selectionPressRef.current = null;
|
||||
handlePanResponderScroll(gesture.dy);
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent === "select") {
|
||||
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
|
||||
updateSelectionAtPoint(point);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
onPanResponderRelease: (event, gesture) => {
|
||||
clearSelectionLongPressTimeout();
|
||||
const selectionPress = selectionPressRef.current;
|
||||
if (
|
||||
selectionPress?.status === "selecting" &&
|
||||
panStateRef.current.movedBeyondTapTolerance
|
||||
) {
|
||||
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
|
||||
}
|
||||
selectionPressRef.current = null;
|
||||
const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current;
|
||||
panStateRef.current = {
|
||||
lastDy: 0,
|
||||
rowRemainder: 0,
|
||||
didScroll: false,
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
};
|
||||
const releaseAction = resolveTerminalGestureReleaseAction({
|
||||
status: selectionPress?.status ?? "idle",
|
||||
startedWithSelection: selectionPress?.startedWithSelection,
|
||||
didScroll,
|
||||
didNavigate,
|
||||
movedBeyondTapTolerance,
|
||||
pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0,
|
||||
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
|
||||
scrollMode: terminalScreenRef.current?.scroll.mode ?? "following",
|
||||
});
|
||||
if (releaseAction === "select" && selectionPress) {
|
||||
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
|
||||
return;
|
||||
}
|
||||
if (releaseAction === "focus" || releaseAction === "clear") {
|
||||
focusTerminalFromTap();
|
||||
}
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
clearSelectionLongPressTimeout();
|
||||
selectionPressRef.current = null;
|
||||
panStateRef.current = {
|
||||
lastDy: 0,
|
||||
rowRemainder: 0,
|
||||
didScroll: false,
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
};
|
||||
},
|
||||
onPanResponderMove: (_event, gesture) =>
|
||||
selectionController.move({
|
||||
x: gestureStartRef.current.x + gesture.dx,
|
||||
y: gestureStartRef.current.y + gesture.dy,
|
||||
}),
|
||||
onPanResponderRelease: () => selectionController.release(),
|
||||
onPanResponderTerminate: () => selectionController.cancel(),
|
||||
}),
|
||||
[selectionController],
|
||||
[
|
||||
beginSelectionAtPoint,
|
||||
clearSelectionLongPressTimeout,
|
||||
focusTerminalFromTap,
|
||||
handlePanResponderScroll,
|
||||
updateSelectionAtPoint,
|
||||
],
|
||||
);
|
||||
|
||||
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
|
||||
@@ -699,11 +911,6 @@ function NativeTerminalEmulator({
|
||||
|
||||
const terminalState = terminalScreen?.viewport ?? null;
|
||||
const isScrolled = terminalScreen?.scroll.mode === "scrolled";
|
||||
const viewport = resolveSelectionViewport();
|
||||
const handles =
|
||||
viewport && cellMetrics
|
||||
? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics })
|
||||
: [];
|
||||
const terminalGrid = terminalState ? (
|
||||
<TerminalGridView
|
||||
state={terminalState}
|
||||
@@ -720,41 +927,6 @@ function NativeTerminalEmulator({
|
||||
<View onLayout={handleLayout} style={rootStyle} testID={testId}>
|
||||
<View {...panResponder.panHandlers} accessible={false} style={styles.nativeSurface}>
|
||||
{terminalGrid}
|
||||
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
||||
{handles.map((handle) => (
|
||||
<View key={handle.endpoint}>
|
||||
<View
|
||||
style={[
|
||||
styles.selectionStem,
|
||||
{
|
||||
left: Math.max(
|
||||
0,
|
||||
Math.min(handle.x - 1, viewport!.cols * cellMetrics!.cellWidth - 2),
|
||||
),
|
||||
top: handle.stemTop,
|
||||
height: handle.stemHeight,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Svg
|
||||
width={TERMINAL_SELECTION_GRIP_SIZE}
|
||||
height={TERMINAL_SELECTION_GRIP_SIZE}
|
||||
viewBox="0 0 18 18"
|
||||
style={[styles.selectionGrip, { left: handle.gripX - 9, top: handle.gripY - 9 }]}
|
||||
>
|
||||
<Path
|
||||
fill="#2684ff"
|
||||
d={
|
||||
handle.gripDirection === "left"
|
||||
? "M18 0H9C4 0 0 4 0 9S4 18 9 18 18 14 18 9Z"
|
||||
: "M0 0H9C14 0 18 4 18 9S14 18 9 18 0 14 0 9Z"
|
||||
}
|
||||
transform={handle.gripAbove ? "translate(0 18) scale(1 -1)" : undefined}
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<TerminalInput
|
||||
ref={inputRef}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
@@ -797,8 +969,6 @@ const styles = StyleSheet.create({
|
||||
nativeGrid: {
|
||||
flex: 1,
|
||||
},
|
||||
selectionGrip: { position: "absolute" },
|
||||
selectionStem: { position: "absolute", width: 2, backgroundColor: "#2684ff" },
|
||||
followButton: {
|
||||
position: "absolute",
|
||||
right: 12,
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
|
||||
import type { TerminalFile } from "../terminal/file-paste";
|
||||
import type { TerminalCharacterWidths } from "../terminal/character-widths";
|
||||
import type {
|
||||
TerminalLocalFileLinkSource,
|
||||
TerminalLocalFileLinkTarget,
|
||||
@@ -97,7 +96,6 @@ interface TerminalEmulatorProps {
|
||||
ref: Ref<TerminalEmulatorHandle>;
|
||||
streamKey: string;
|
||||
supportsTerminalInputModeReplay: boolean;
|
||||
characterWidths: TerminalCharacterWidths;
|
||||
testId?: string;
|
||||
xtermTheme?: ITheme;
|
||||
scrollbackLines: number;
|
||||
@@ -156,7 +154,6 @@ export default function TerminalEmulator({
|
||||
cursor: "#e6e6e6",
|
||||
},
|
||||
scrollbackLines,
|
||||
characterWidths,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
swipeGesturesEnabled = false,
|
||||
@@ -186,8 +183,6 @@ export default function TerminalEmulator({
|
||||
const fontSizeRef = useRef(fontSize);
|
||||
const scrollbackLinesRef = useRef(scrollbackLines);
|
||||
scrollbackLinesRef.current = scrollbackLines;
|
||||
const characterWidthsRef = useRef(characterWidths);
|
||||
characterWidthsRef.current = characterWidths;
|
||||
fontFamilyRef.current = fontFamily;
|
||||
fontSizeRef.current = fontSize;
|
||||
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
||||
@@ -320,10 +315,6 @@ export default function TerminalEmulator({
|
||||
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
|
||||
}, [scrollbackLines]);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeRef.current?.setCharacterWidths({ widths: characterWidths });
|
||||
}, [characterWidths]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || !swipeGesturesEnabled) {
|
||||
@@ -456,7 +447,6 @@ export default function TerminalEmulator({
|
||||
},
|
||||
});
|
||||
runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current });
|
||||
runtime.setCharacterWidths({ widths: characterWidthsRef.current });
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find";
|
||||
import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime";
|
||||
import type { TerminalCharacterWidths } from "@/terminal/character-widths";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -305,11 +304,6 @@ export function TerminalPane({
|
||||
const supportsTerminalSizeOwnership = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true,
|
||||
);
|
||||
const characterWidths: TerminalCharacterWidths = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.serverInfo?.features?.["terminal-grapheme-widths"] === true
|
||||
? "graphemes"
|
||||
: "legacy",
|
||||
);
|
||||
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
@@ -1177,7 +1171,6 @@ export function TerminalPane({
|
||||
dom={TERMINAL_EMULATOR_DOM_PROPS}
|
||||
streamKey={terminalStreamKey}
|
||||
supportsTerminalInputModeReplay={supportsTerminalInputModeReplay}
|
||||
characterWidths={characterWidths}
|
||||
testId="terminal-surface"
|
||||
xtermTheme={xtermTheme}
|
||||
scrollbackLines={settings.terminalScrollbackLines}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -13,7 +12,11 @@ import {
|
||||
Terminal,
|
||||
} from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
@@ -159,13 +162,20 @@ function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomeScreen() {
|
||||
export interface WelcomeScreenProps {
|
||||
onHostAdded?: (profile: HostProfile) => void;
|
||||
}
|
||||
|
||||
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const appVersion = resolveAppVersion();
|
||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||
const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false);
|
||||
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
||||
const hosts = useHosts();
|
||||
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||
|
||||
@@ -186,10 +196,23 @@ export function WelcomeScreen() {
|
||||
router.push("/settings");
|
||||
}, [router]);
|
||||
|
||||
const handleOpenDirect = useAddHost({ method: "direct", onAdded: finishOnboarding });
|
||||
const handleOpenRemoteSsh = useAddHost({ method: "ssh", onAdded: finishOnboarding });
|
||||
const handleOpenPasteLink = useAddHost({ method: "link", onAdded: finishOnboarding });
|
||||
const handleScanQr = useAddHost({ method: "scan", onAdded: finishOnboarding });
|
||||
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
|
||||
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
|
||||
const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []);
|
||||
const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []);
|
||||
const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []);
|
||||
const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []);
|
||||
const handleScanQr = useCallback(() => {
|
||||
router.push("/pair-scan?source=onboarding");
|
||||
}, [router]);
|
||||
|
||||
const handleHostSaved = useCallback(
|
||||
({ profile }: { profile: HostProfile; serverId: string }) => {
|
||||
onHostAdded?.(profile);
|
||||
finishOnboarding();
|
||||
},
|
||||
[onHostAdded, finishOnboarding],
|
||||
);
|
||||
|
||||
const actions: WelcomeAction[] =
|
||||
isWeb || isFdroidBuild
|
||||
@@ -293,6 +316,24 @@ export function WelcomeScreen() {
|
||||
</Button>
|
||||
</View>
|
||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||
|
||||
<AddHostModal
|
||||
visible={isDirectOpen}
|
||||
onClose={handleCloseDirect}
|
||||
onSaved={handleHostSaved}
|
||||
/>
|
||||
|
||||
<AddRemoteSshHostModal
|
||||
visible={isRemoteSshOpen}
|
||||
onClose={handleCloseRemoteSsh}
|
||||
onSaved={handleHostSaved}
|
||||
/>
|
||||
|
||||
<PairLinkModal
|
||||
visible={isPasteLinkOpen}
|
||||
onClose={handleClosePasteLink}
|
||||
onSaved={handleHostSaved}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
@@ -126,13 +126,17 @@ afterEach(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
|
||||
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
|
||||
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
beforeAll(async () => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
({ useAgentInputDraft } = await import("./input-draft"));
|
||||
});
|
||||
const { useAgentInputDraft } = await import("./input-draft");
|
||||
|
||||
describe("useAgentInputDraft live contract", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { TextReplacement } from "@/composer/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { DraftCommandTarget } from "@/agent-commands";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
useAgentFormState,
|
||||
type CreateAgentInitialValues,
|
||||
|
||||
@@ -2,31 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { ComposerEditingSession, ComposerEditingSessions } from "./index";
|
||||
|
||||
describe("composer editing session", () => {
|
||||
it("appends commands to the current editor buffer and focuses it", () => {
|
||||
const session = new ComposerEditingSession();
|
||||
let text = "draft typed since publication";
|
||||
let focuses = 0;
|
||||
let selection = { start: 0, end: 0 };
|
||||
const detach = session.attachEditor({
|
||||
getText: () => text,
|
||||
replaceText: (next, nextSelection) => {
|
||||
text = next;
|
||||
selection = nextSelection;
|
||||
},
|
||||
focus: () => {
|
||||
focuses += 1;
|
||||
},
|
||||
});
|
||||
session.insertCommand("/review");
|
||||
expect(selection).toEqual({ start: text.length, end: text.length });
|
||||
session.insertCommand("/test");
|
||||
expect(text).toBe("draft typed since publication /review /test ");
|
||||
expect(focuses).toBe(2);
|
||||
expect(selection).toEqual({ start: text.length, end: text.length });
|
||||
detach();
|
||||
session.insertCommand("/ignored");
|
||||
expect(text).toBe("draft typed since publication /review /test ");
|
||||
});
|
||||
it("publishes fullscreen changes once per transition", () => {
|
||||
const session = new ComposerEditingSession();
|
||||
const changes: boolean[] = [];
|
||||
|
||||
@@ -30,29 +30,10 @@ export interface ComposerRetainedEditor {
|
||||
*/
|
||||
export class ComposerEditingSession {
|
||||
private fullscreen = false;
|
||||
private editor: {
|
||||
getText(): string;
|
||||
replaceText(text: string, selection: ComposerSelection): void;
|
||||
focus(): void;
|
||||
} | null = null;
|
||||
private retained: ComposerRetainedEditor | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
constructor(private readonly onChange?: () => void) {}
|
||||
getFullscreen = () => this.fullscreen;
|
||||
attachEditor(editor: NonNullable<ComposerEditingSession["editor"]>) {
|
||||
this.editor = editor;
|
||||
return () => {
|
||||
if (this.editor === editor) this.editor = null;
|
||||
};
|
||||
}
|
||||
insertCommand(command: string) {
|
||||
const editor = this.editor;
|
||||
if (!editor) return;
|
||||
const text = editor.getText();
|
||||
const nextText = `${text}${text && !/\s$/.test(text) ? " " : ""}${command} `;
|
||||
editor.replaceText(nextText, { start: nextText.length, end: nextText.length });
|
||||
editor.focus();
|
||||
}
|
||||
/** Called by the unmounting editor; the next mount of this session takes it. */
|
||||
retainEditor(retained: ComposerRetainedEditor) {
|
||||
this.retained = retained;
|
||||
|
||||
@@ -71,7 +71,7 @@ import {
|
||||
} from "./input/input";
|
||||
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { DraftCommandTarget } from "@/agent-commands";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import {
|
||||
|
||||
@@ -562,6 +562,7 @@ function MessageInputOverlay({
|
||||
isDictationProcessing,
|
||||
dictationStatus,
|
||||
dictationError,
|
||||
dictationEmptyReason,
|
||||
onCancelRecording,
|
||||
onAcceptRecording,
|
||||
onAcceptAndSendRecording,
|
||||
@@ -585,6 +586,7 @@ function MessageInputOverlay({
|
||||
isDictationProcessing: boolean;
|
||||
dictationStatus: React.ComponentProps<typeof DictationOverlay>["status"];
|
||||
dictationError: string | null;
|
||||
dictationEmptyReason: React.ComponentProps<typeof DictationOverlay>["emptyReason"];
|
||||
onCancelRecording: () => Promise<void>;
|
||||
onAcceptRecording: () => Promise<void>;
|
||||
onAcceptAndSendRecording: () => Promise<void>;
|
||||
@@ -593,6 +595,7 @@ function MessageInputOverlay({
|
||||
onRealtimeVoiceStop: () => void;
|
||||
}) {
|
||||
if (showDictationOverlay) {
|
||||
const awaitsAcknowledgement = dictationStatus === "failed" || dictationStatus === "empty";
|
||||
return (
|
||||
<DictationOverlay
|
||||
volume={dictationVolume}
|
||||
@@ -601,11 +604,12 @@ function MessageInputOverlay({
|
||||
isProcessing={isDictationProcessing}
|
||||
status={dictationStatus}
|
||||
errorText={dictationStatus === "failed" ? (dictationError ?? undefined) : undefined}
|
||||
emptyReason={dictationEmptyReason}
|
||||
onCancel={onCancelRecording}
|
||||
onAccept={onAcceptRecording}
|
||||
onAcceptAndSend={onAcceptAndSendRecording}
|
||||
onRetry={dictationStatus === "failed" ? onRetryFailedRecording : undefined}
|
||||
onDiscard={dictationStatus === "failed" ? onDiscardFailedRecording : undefined}
|
||||
onRetry={awaitsAcknowledgement ? onRetryFailedRecording : undefined}
|
||||
onDiscard={awaitsAcknowledgement ? onDiscardFailedRecording : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1032,7 +1036,12 @@ function computeShouldShowDictationOverlay(
|
||||
isDictationProcessing: boolean,
|
||||
dictationStatus: string,
|
||||
): boolean {
|
||||
return isDictating || isDictationProcessing || dictationStatus === "failed";
|
||||
return (
|
||||
isDictating ||
|
||||
isDictationProcessing ||
|
||||
dictationStatus === "failed" ||
|
||||
dictationStatus === "empty"
|
||||
);
|
||||
}
|
||||
|
||||
function computeIsDictationStartEnabled(
|
||||
@@ -1407,16 +1416,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
|
||||
);
|
||||
|
||||
useLayoutEffect(
|
||||
() =>
|
||||
editingSession.attachEditor({
|
||||
getText: () => textInputRef.current?.getText() ?? valueRef.current,
|
||||
replaceText,
|
||||
focus: () => textInputRef.current?.focus(),
|
||||
}),
|
||||
[editingSession, replaceText],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
textInputRef.current?.focus();
|
||||
@@ -1543,6 +1542,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
duration: dictationDuration,
|
||||
error: dictationError,
|
||||
status: dictationStatus,
|
||||
emptyReason: dictationEmptyReason,
|
||||
startDictation,
|
||||
cancelDictation,
|
||||
confirmDictation,
|
||||
@@ -2190,6 +2190,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isDictationProcessing={isDictationProcessing}
|
||||
dictationStatus={dictationStatus}
|
||||
dictationError={dictationError}
|
||||
dictationEmptyReason={dictationEmptyReason}
|
||||
onCancelRecording={handleCancelRecording}
|
||||
onAcceptRecording={handleAcceptRecording}
|
||||
onAcceptAndSendRecording={handleAcceptAndSendRecording}
|
||||
@@ -2211,6 +2212,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isDictationProcessing,
|
||||
dictationStatus,
|
||||
dictationError,
|
||||
dictationEmptyReason,
|
||||
handleCancelRecording,
|
||||
handleAcceptRecording,
|
||||
handleAcceptAndSendRecording,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./provider-snapshot-cache";
|
||||
import { queryClient as singletonQueryClient } from "./query-client";
|
||||
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
|
||||
import { agentCommandsQueryRoot } from "@/agent-commands/cache";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import type {
|
||||
ListTerminalsResponse,
|
||||
MutableDaemonConfig,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { invalidateCommandsForProviderChange } from "@/agent-commands/cache";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import { shareCheckoutDiff } from "@/git/diff-sharing";
|
||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
@@ -187,10 +186,10 @@ export async function applyProvidersSnapshotUpdate(input: {
|
||||
const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" };
|
||||
const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd);
|
||||
const queryKey = providersSnapshotQueryKey(input.serverId, cwd);
|
||||
const previous = input.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(queryKey);
|
||||
const previous = input.queryClient.getQueryData<{ snapshotHash?: string }>(queryKey);
|
||||
let announcement: typeof snapshot | undefined = snapshot;
|
||||
void input.queryClient.cancelQueries({ queryKey, exact: true });
|
||||
const current = await input.queryClient.fetchQuery({
|
||||
await input.queryClient.fetchQuery({
|
||||
queryKey,
|
||||
staleTime: 0,
|
||||
structuralSharing: false,
|
||||
@@ -206,13 +205,12 @@ export async function applyProvidersSnapshotUpdate(input: {
|
||||
});
|
||||
},
|
||||
});
|
||||
void invalidateCommandsForProviderChange({
|
||||
queryClient: input.queryClient,
|
||||
serverId: input.serverId,
|
||||
cwd,
|
||||
previous: previous?.entries,
|
||||
current: current.entries,
|
||||
});
|
||||
if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) {
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: agentCommandsQueryRoot(input.serverId),
|
||||
exact: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function mountServerDataPushRouter(input: PushRouterInput): () => void {
|
||||
|
||||
@@ -27,7 +27,7 @@ type ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey>
|
||||
|
||||
type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
"initialData" | "placeholderData" | "queryFn" | "staleTime"
|
||||
"initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime"
|
||||
> & {
|
||||
dataShape: "list" | "value";
|
||||
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
|
||||
@@ -113,7 +113,7 @@ export function fetchQueryOptions<
|
||||
dataShape,
|
||||
},
|
||||
},
|
||||
refetchOnMount: options.refetchOnMount ?? (immutableWhen ? true : "always"),
|
||||
refetchOnMount: immutableWhen ? true : "always",
|
||||
staleTime: immutableWhen
|
||||
? (query) =>
|
||||
query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { completeDictation, type DictationRecording } from "./result";
|
||||
|
||||
class RecordedAudio implements DictationRecording {
|
||||
constructor(private segments: string[]) {}
|
||||
getSegmentCount() {
|
||||
return this.segments.length;
|
||||
}
|
||||
clearAll() {
|
||||
this.segments = [];
|
||||
}
|
||||
}
|
||||
|
||||
describe("completing dictation", () => {
|
||||
it("keeps a recording through repeated empty results until a retry returns text", () => {
|
||||
const recording = new RecordedAudio(["first PCM chunk", "second PCM chunk"]);
|
||||
const empty = { finalText: " ", partialText: "", recording };
|
||||
const result = completeDictation(empty);
|
||||
expect(recording.getSegmentCount()).toBe(2);
|
||||
expect(result).toEqual({ status: "empty", reason: "no-transcript" });
|
||||
expect(completeDictation(empty)).toEqual({ status: "empty", reason: "no-transcript" });
|
||||
expect(recording.getSegmentCount()).toBe(2);
|
||||
expect(completeDictation({ ...empty, finalText: " recovered " })).toEqual({
|
||||
status: "transcribed",
|
||||
text: "recovered",
|
||||
});
|
||||
expect(recording.getSegmentCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps usable partial text when the final response is empty", () => {
|
||||
const recording = new RecordedAudio(["PCM chunk"]);
|
||||
expect(
|
||||
completeDictation({ finalText: "", partialText: " recognized words ", recording }),
|
||||
).toEqual({
|
||||
status: "transcribed",
|
||||
text: "recognized words",
|
||||
});
|
||||
expect(recording.getSegmentCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("reports a missing recording without inventing a reason for the empty result", () => {
|
||||
expect(
|
||||
completeDictation({ finalText: "", partialText: "", recording: new RecordedAudio([]) }),
|
||||
).toEqual({
|
||||
status: "empty",
|
||||
reason: "no-audio",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export type DictationEmptyReason = "no-audio" | "no-transcript";
|
||||
|
||||
export interface DictationRecording {
|
||||
getSegmentCount(): number;
|
||||
clearAll(): void;
|
||||
}
|
||||
|
||||
export type DictationResult =
|
||||
| { status: "empty"; reason: DictationEmptyReason }
|
||||
| { status: "transcribed"; text: string };
|
||||
|
||||
export function completeDictation(input: {
|
||||
finalText: string;
|
||||
partialText: string;
|
||||
recording: DictationRecording;
|
||||
}): DictationResult {
|
||||
const text = input.finalText.trim() || input.partialText.trim();
|
||||
if (!text) {
|
||||
return {
|
||||
status: "empty",
|
||||
reason: input.recording.getSegmentCount() === 0 ? "no-audio" : "no-transcript",
|
||||
};
|
||||
}
|
||||
input.recording.clearAll();
|
||||
return { status: "transcribed", text };
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
resolveWorkingDiffComparison,
|
||||
selectWorkingDiffComparison,
|
||||
} from "@/git/working-diff-comparison";
|
||||
import { draftAgentCommandsQueryKey } from "@/agent-commands/cache";
|
||||
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||
import {
|
||||
applyCheckoutStatusUpdateFromEvent,
|
||||
ensureCheckoutStatus,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
invalidatePrPaneTimelineForCheckout,
|
||||
} from "@/git/query-keys";
|
||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
import { resetDraftAgentCommandsForCheckout } from "@/agent-commands/cache";
|
||||
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
|
||||
+1
-51
@@ -1,61 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { fetchQueryOptions } from "@/data/query";
|
||||
import {
|
||||
agentCommandsQueryKey,
|
||||
agentCommandsQueryRoot,
|
||||
draftAgentCommandsQueryKey,
|
||||
normalizeAgentCommandsCwd,
|
||||
sessionAgentCommandsQueryKey,
|
||||
sessionCommandQueryPolicy,
|
||||
} from "./cache";
|
||||
|
||||
it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { gcTime: 300_000 } } });
|
||||
const key = sessionAgentCommandsQueryKey({ serverId: "s", agentId: "a" });
|
||||
let calls = 0;
|
||||
let fail = false;
|
||||
let resolveRefresh: (value: string[]) => void = () => {};
|
||||
const refresh = new Promise<string[]>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
const options = fetchQueryOptions({
|
||||
...sessionCommandQueryPolicy,
|
||||
staleTimeMs: sessionCommandQueryPolicy.staleTime,
|
||||
dataShape: "value",
|
||||
queryKey: key,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
calls++;
|
||||
if (fail) throw new Error("offline");
|
||||
return refresh;
|
||||
},
|
||||
});
|
||||
client.setQueryData(key, ["old"]);
|
||||
const fresh = new QueryObserver(client, options);
|
||||
const unsubscribeFresh = fresh.subscribe(() => {});
|
||||
expect(calls).toBe(0);
|
||||
unsubscribeFresh();
|
||||
client.setQueryData(key, ["old"], { updatedAt: 1 });
|
||||
const first = new QueryObserver(client, options);
|
||||
const second = new QueryObserver(client, options);
|
||||
const unsubscribeFirst = first.subscribe(() => {});
|
||||
const unsubscribeSecond = second.subscribe(() => {});
|
||||
expect(calls).toBe(1);
|
||||
expect(first.getCurrentResult().data).toEqual(["old"]);
|
||||
expect(client.getQueryCache().find({ queryKey: key })?.gcTime).toBe(1_800_000);
|
||||
resolveRefresh(["new"]);
|
||||
await refresh;
|
||||
await first.refetch({ cancelRefetch: false });
|
||||
expect(second.getCurrentResult().data).toEqual(["new"]);
|
||||
fail = true;
|
||||
await client.invalidateQueries({ queryKey: key });
|
||||
expect(first.getCurrentResult().data).toEqual(["new"]);
|
||||
expect(first.getCurrentResult().isError).toBe(true);
|
||||
unsubscribeFirst();
|
||||
unsubscribeSecond();
|
||||
client.clear();
|
||||
});
|
||||
} from "./agent-commands-query";
|
||||
|
||||
describe("agent command query keys", () => {
|
||||
it("shares draft commands across draft tabs with the same provider setup", () => {
|
||||
-48
@@ -1,16 +1,8 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import equal from "fast-deep-equal";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
|
||||
export const sessionCommandQueryPolicy = {
|
||||
staleTime: 600_000,
|
||||
gcTime: 1_800_000,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
|
||||
export interface AgentCommandsDraftConfig {
|
||||
provider: AgentProvider;
|
||||
@@ -29,46 +21,6 @@ export function agentCommandsQueryRoot(serverId: string) {
|
||||
return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const;
|
||||
}
|
||||
|
||||
/** Snapshot announcements contain provider metadata, not command catalogs. Ignore initial
|
||||
* discovery and unrelated providers; explicit refresh still invalidates every catalog. */
|
||||
export function invalidateCommandsForProviderChange(input: {
|
||||
queryClient: QueryClient;
|
||||
serverId: string;
|
||||
cwd: string | null;
|
||||
previous: readonly ProviderSnapshotEntry[] | undefined;
|
||||
current: readonly ProviderSnapshotEntry[];
|
||||
}): Promise<void> {
|
||||
const previous = new Map((input.previous ?? []).map((entry) => [entry.provider, entry]));
|
||||
const current = new Map(input.current.map((entry) => [entry.provider, entry]));
|
||||
const relevant = (entry: ProviderSnapshotEntry | undefined) =>
|
||||
entry && {
|
||||
status: entry.status,
|
||||
enabled: entry.enabled,
|
||||
source: entry.source,
|
||||
models: entry.models,
|
||||
modes: entry.modes,
|
||||
defaultModeId: entry.defaultModeId,
|
||||
};
|
||||
return input.queryClient.invalidateQueries({
|
||||
queryKey: agentCommandsQueryRoot(input.serverId),
|
||||
predicate: (query) => {
|
||||
const isDraft = query.queryKey[2] === "draft";
|
||||
const provider =
|
||||
query.meta?.agentCommandProvider ?? (isDraft ? query.queryKey[6] : undefined);
|
||||
const cwd = query.meta?.agentCommandCwd ?? (isDraft ? query.queryKey[4] : undefined);
|
||||
if (input.cwd && typeof cwd === "string" && normalizeAgentCommandsCwd(cwd) !== input.cwd)
|
||||
return false;
|
||||
if (typeof provider !== "string") return false;
|
||||
const before = previous.get(provider);
|
||||
const after = current.get(provider);
|
||||
if (!before) return after?.status === "ready" && query.state.status === "error";
|
||||
if (before.status !== "ready")
|
||||
return after?.status === "ready" && query.state.status === "error";
|
||||
return !equal(relevant(before), relevant(after));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) {
|
||||
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import {
|
||||
useAutocompleteCommands,
|
||||
useAgentCommandsQuery,
|
||||
type AgentSlashCommand,
|
||||
type DraftCommandConfig,
|
||||
type DraftCommandTarget,
|
||||
} from "@/agent-commands";
|
||||
} from "./use-agent-commands-query";
|
||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||
import { useAutocomplete } from "./use-autocomplete";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -432,7 +432,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
autocompleteCwd,
|
||||
});
|
||||
|
||||
const commandsQuery = useAutocompleteCommands({
|
||||
const commandsQuery = useAgentCommandsQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: mode === "command" && canLoadCommands && canListCommands,
|
||||
|
||||
+5
-7
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type AgentCommandsClient, type DraftCommandConfig, fetchAgentCommands } from "./use-query";
|
||||
import {
|
||||
type AgentCommandsClient,
|
||||
type DraftCommandConfig,
|
||||
fetchAgentCommands,
|
||||
} from "./use-agent-commands-query";
|
||||
|
||||
type ListCommands = AgentCommandsClient["listCommands"];
|
||||
type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
|
||||
@@ -34,12 +38,6 @@ function commandsPayload(commands: ListCommandsResult["commands"]): ListCommands
|
||||
}
|
||||
|
||||
describe("fetchAgentCommands", () => {
|
||||
it("rejects a discovery error so a previous catalog is retained", async () => {
|
||||
const client = createClient({ ...commandsPayload([]), error: "provider unavailable" });
|
||||
await expect(fetchAgentCommands({ client, agentId: "agent-1" })).rejects.toThrow(
|
||||
"provider unavailable",
|
||||
);
|
||||
});
|
||||
it("loads commands for a draft composer without an agent id", async () => {
|
||||
const client = createClient(
|
||||
commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]),
|
||||
+8
-28
@@ -1,15 +1,12 @@
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
agentCommandsQueryKey,
|
||||
sessionCommandQueryPolicy,
|
||||
type AgentCommandsDraftConfig,
|
||||
} from "./cache";
|
||||
import { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query";
|
||||
|
||||
const DRAFT_COMMANDS_STALE_TIME = Number.POSITIVE_INFINITY;
|
||||
const SESSION_COMMANDS_STALE_TIME = 60_000;
|
||||
|
||||
export interface AgentSlashCommand {
|
||||
name: string;
|
||||
@@ -44,7 +41,6 @@ export async function fetchAgentCommands(input: {
|
||||
agentId: input.agentId,
|
||||
draftConfig: input.draftConfig,
|
||||
});
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.commands as AgentSlashCommand[];
|
||||
}
|
||||
|
||||
@@ -66,16 +62,8 @@ export function useAgentCommandsQuery({
|
||||
const queryEnabled = enabled && retainedPanelActive;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { provider, cwd } = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent =
|
||||
state.sessions[serverId]?.agents?.get(agentId) ??
|
||||
state.sessions[serverId]?.agentDetails?.get(agentId);
|
||||
return { provider: agent?.provider, cwd: agent?.cwd };
|
||||
}),
|
||||
);
|
||||
|
||||
const query = useFetchQuery({
|
||||
const query = useQuery({
|
||||
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
@@ -84,15 +72,7 @@ export function useAgentCommandsQuery({
|
||||
return fetchAgentCommands({ client, agentId, draftConfig });
|
||||
},
|
||||
enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig),
|
||||
dataShape: "value",
|
||||
meta: {
|
||||
agentCommandProvider: draftConfig?.provider ?? provider,
|
||||
agentCommandCwd: draftConfig?.cwd ?? cwd,
|
||||
},
|
||||
...(draftConfig
|
||||
? { immutableWhen: () => true as const }
|
||||
: { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }),
|
||||
subscribed: queryEnabled,
|
||||
staleTime: draftConfig ? DRAFT_COMMANDS_STALE_TIME : SESSION_COMMANDS_STALE_TIME,
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
||||
});
|
||||
@@ -104,7 +84,7 @@ export function useAgentCommandsQuery({
|
||||
return {
|
||||
commands: query.data ?? [],
|
||||
isLoading,
|
||||
isError: query.isError && query.data === undefined,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user