diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f61d514727..285ff8e072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: [main] - # Every base branch, so stacked PRs and PRs into `next` get the same checks. + # Every base branch, so stacked PRs and PRs into any base get the same checks. pull_request: merge_group: workflow_dispatch: @@ -281,10 +281,10 @@ jobs: # otherwise has no server-test coverage. Scoped to the observer/watcher suites to # bound runtime. server-tests-macos: - name: server-tests (macos-14, file observation) + name: server-tests (macos-latest, file observation) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} - runs-on: macos-14 + runs-on: macos-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index d81c036e62..26be12291b 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -100,10 +100,10 @@ jobs: fail-fast: false matrix: include: - - runner: macos-14 + - runner: macos-latest electron_arch: arm64 node_heap_mb: 4096 - - runner: macos-15-intel + - runner: macos-26-intel electron_arch: x64 node_heap_mb: 8192 permissions: diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index f37a8b0cdf..b653564793 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -101,7 +101,7 @@ jobs: run: nix build .#desktop -o result-desktop build-desktop-darwin: - runs-on: macos-14 + runs-on: macos-latest steps: - uses: actions/checkout@v4 diff --git a/.oxlintrc.json b/.oxlintrc.json index c82b517844..ae335c0e48 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -406,10 +406,7 @@ { "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", @@ -428,7 +425,6 @@ "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", @@ -543,14 +539,10 @@ "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", diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a11f55d5b..6ee9a07b36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,83 @@ # Changelog -Not released yet. These changes are on `main` but not in 0.14.0-beta.2; the next release entry includes them. +## 0.15.0-beta.1 - 2026-11-01 -Added: +Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations. -- 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)) +Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history. -Improved: +Worktree setup can copy ignored files and use plugin-defined locations before agents start. -- 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)) +### Added -## 0.14.0-beta.2 - 2026-10-28 +- 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 + +## 0.14.0 - 2026-10-29 Plugins can now add tools to individual agents and configure their MCP servers when sessions open. @@ -43,7 +109,6 @@ Voice settings now let you configure speech providers and download local models - Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan)) - Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959)) - Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944)) -- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss)) ### Fixed @@ -72,7 +137,8 @@ Voice settings now let you configure speech providers and download local models - Voice mode now defaults to off; enable it in Voice settings - If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings -- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta +- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by 0.14.0 +- Install the optional [Claude Code entrypoint plugin](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env) to launch Claude sessions with `CLAUDE_CODE_ENTRYPOINT=Paseo`, a community workaround for subscription limits draining faster ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss)) ## 0.13.2 - 2026-10-26 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index a089401f39..532ed24887 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,17 +1,83 @@ -# 更新日志 +# Changelog -尚未发布。以下变更已合入 `main`,但不包含在 0.14.0-beta.2 中;下一个版本的条目会包含它们。 +## 0.15.0-beta.1 - 2026-11-01 -新增: +daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。 -- 在 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)) +移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。 -改进: +worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用插件指定的位置。 -- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081)) +### 新增 -## 0.14.0-beta.2 - 2026-10-28 +- 点击回复中的已知 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 恢复和用量查询设置 + +## 0.14.0 - 2026-10-29 插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。 @@ -43,7 +109,6 @@ - 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan)) - 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959)) - 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944)) -- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss)) ### 修复 @@ -72,7 +137,8 @@ - 语音模式默认关闭,可在语音设置中启用 - 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置 -- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置 +- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 0.14.0 保存的语音凭据和空闲清理设置 +- 安装可选的 [Claude Code entrypoint 插件](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env),即可使用 `CLAUDE_CODE_ENTRYPOINT=Paseo` 启动 Claude 会话;这是针对订阅额度消耗过快问题的社区变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss)) ## 0.13.2 - 2026-10-26 diff --git a/CLAUDE.md b/CLAUDE.md index d72205a01e..71682af7e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries | | [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | +| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression | | [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render | | [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints | | [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations | @@ -117,10 +118,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir ## Release branches -When the user says "this goes to next", create or -retarget the PR to `next` and preserve that destination through delivery. Follow -[release branch discipline](docs/release.md#release-branch-discipline) for creating -and updating `next`, integrating it after a release, and releasing a hotfix from a tag. +Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each +release builds in a short-lived release workspace from `origin/main` or a release tag; see +[release base and source](docs/release.md#release-base-and-source). ## Critical rules diff --git a/assets/store-screenshots/README.md b/assets/store-screenshots/README.md index 6db3d236ec..fc37d98bd8 100644 --- a/assets/store-screenshots/README.md +++ b/assets/store-screenshots/README.md @@ -95,4 +95,8 @@ Callout crops are in native capture pixels. To find them, overlay a 100 px grid 6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview. +## Publish + +The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports. + Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en). diff --git a/assets/store-screenshots/render.mjs b/assets/store-screenshots/render.mjs index 9e32384a60..6695eac559 100644 --- a/assets/store-screenshots/render.mjs +++ b/assets/store-screenshots/render.mjs @@ -9,7 +9,7 @@ // elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac. import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -23,6 +23,7 @@ const root = path.dirname(fileURLToPath(import.meta.url)); const repo = path.resolve(root, "../.."); const raw = (device, name) => path.join(root, "raw", device, `${name}.png`); const output = path.join(root, "output"); +const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images"); // Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at // 2x. The frame's screen radius and Dynamic Island are in capture pixels. @@ -116,6 +117,7 @@ const STORES = { }, google: { device: "iphone", + androidScreenshots: "phoneScreenshots", width: 1440, height: 2560, textTop: 150, @@ -134,6 +136,7 @@ const STORES = { }, "google-tablet": { device: "ipad", + androidScreenshots: "tenInchScreenshots", width: 1800, height: 3200, textTop: 200, @@ -352,6 +355,18 @@ async function expectLines(view, selector, expected, label) { if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`); } +// Google Play and F-Droid read their screenshots from the Android metadata, +// which holds links to these renders. Rewriting the links on every render keeps +// added, removed and renamed panels in step with the App Store. +async function linkAndroidScreenshots(folder, files) { + const dir = path.join(androidImages, folder); + await rm(dir, { recursive: true, force: true }); + await mkdir(dir, { recursive: true }); + for (const [index, file] of files.entries()) { + await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`)); + } +} + async function renderPreview(rows) { const tile = 440; const gap = 40; @@ -387,7 +402,9 @@ try { const marks = await loadAgentMarks(work); const rows = []; for (const [name, store] of Object.entries(STORES)) { - rows.push(await renderStore(browser, name, store, font, marks, work)); + const files = await renderStore(browser, name, store, font, marks, work); + if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files); + rows.push(files); } await renderPreview(rows); } finally { diff --git a/docs/android.md b/docs/android.md index 546ba92a3e..17bafd6b24 100644 --- a/docs/android.md +++ b/docs/android.md @@ -61,6 +61,18 @@ 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 @@ -215,7 +227,8 @@ fastlane/metadata/android/ │ ├── images/ │ │ ├── icon.png (512x512) │ │ ├── featureGraphic.png (1024x500) -│ │ └── phoneScreenshots/ (1.png, 2.png, ...) +│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders) +│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders) │ └── changelogs/ (generated — see below) ├── ja/ └── zh-CN/ @@ -223,6 +236,8 @@ fastlane/metadata/android/ Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`. +Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots). + F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result. One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes. diff --git a/docs/architecture.md b/docs/architecture.md index 3c5678b0ab..a7ad69c14d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -493,6 +493,16 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management). +## Outbound networking + +The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY` +(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets +share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct. +A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes +are treated as unset, with one warning naming the variable in `daemon.log`. +Agent and terminal environments are unchanged. System proxy settings and SOCKS-only +`ALL_PROXY` configurations are not supported. + ## Deployment models 1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md index 20c86c1295..7e8b67ee62 100644 --- a/docs/connected-hosts.md +++ b/docs/connected-hosts.md @@ -74,7 +74,10 @@ 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. +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. 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 diff --git a/docs/custom-providers.md b/docs/custom-providers.md index a6c6cefbdc..1d6d67d453 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -65,6 +65,7 @@ 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) @@ -107,6 +108,35 @@ 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": "" + } + } + } + } +} +``` + +`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. @@ -232,7 +262,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. -Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. +Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. The generated Codex provider key is `paseo:`, so a Paseo provider such as `ollama` does not override a Codex built-in. Existing custom sessions created with a non-built-in provider key remain available in Import session. Native Codex conversations using a built-in key stay separate from a custom provider of the same name. ### Setup @@ -260,9 +290,9 @@ Paseo passes those variables through to the Codex app-server process **and** map Under the hood, for each custom Codex provider Paseo injects this into Codex's config: ```toml -model_provider = "my-codex" +model_provider = "paseo:my-codex" -[model_providers.my-codex] +[model_providers."paseo:my-codex"] name = "My Codex" base_url = "https://custom-relay.example.com/v1" wire_api = "responses" diff --git a/docs/design.md b/docs/design.md index a336b80a64..dbf4c028ec 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 `` (`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/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`). +- **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`). - **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`), ` {appVersionText} - - - - - - ); diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 8bc31bb176..82de9d3d73 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -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, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, 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,17 +126,13 @@ afterEach(async () => { }); }); -let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft; type DraftRecordForTest = ReturnType["drafts"][string]; -beforeAll(async () => { - Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { - value: true, - configurable: true, - }); - - ({ useAgentInputDraft } = await import("./input-draft")); +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + value: true, + configurable: true, }); +const { useAgentInputDraft } = await import("./input-draft"); describe("useAgentInputDraft live contract", () => { beforeEach(() => { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index c14db9a119..2eb606a7f7 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -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 "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/agent-commands"; import { useAgentFormState, type CreateAgentInitialValues, diff --git a/packages/app/src/composer/editing-session/index.test.ts b/packages/app/src/composer/editing-session/index.test.ts index 31c3765426..7dfcb0d438 100644 --- a/packages/app/src/composer/editing-session/index.test.ts +++ b/packages/app/src/composer/editing-session/index.test.ts @@ -2,6 +2,31 @@ 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[] = []; diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx index 4776b5af3d..237b15251a 100644 --- a/packages/app/src/composer/editing-session/index.tsx +++ b/packages/app/src/composer/editing-session/index.tsx @@ -30,10 +30,29 @@ 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) { + 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; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index b7088ddeef..d6b4bae63f 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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 "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/agent-commands"; import { encodeImages } from "@/utils/encode-images"; import { focusWithRetries } from "@/utils/web-focus"; import { diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 16acede7e5..8cb8cd4c32 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1407,6 +1407,16 @@ export const MessageInput = forwardRef( [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(); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index bccd4d3ee0..89c32c0bd5 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -455,10 +455,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider reportError: (error) => { console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); }, - schedule: (task, delayMs) => { - const timeout = setTimeout(task, delayMs); - return () => clearTimeout(timeout); - }, }); viewedTimelineSyncRef.current = sync; setViewedTimelineSync(serverId, sync); diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index f0210f1402..46b7d81a2d 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -292,7 +292,10 @@ const CATALOG_DATA = [ version: "0.6.13", iconId: "minimax-code", installLink: "https://agent.minimax.io", - command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"], + // `-p ` names the package and `mcode` names its binary. Without the + // explicit binary, npx resolves the executable from the unscoped package + // name (`code`), which this package does not publish. + command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"], }, { id: "minion-code", diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 68b389fe15..87c62f81ca 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -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 "@/hooks/agent-commands-query"; +import { agentCommandsQueryRoot } from "@/agent-commands/cache"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 75ac313169..cc32f8f8e3 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -1,11 +1,12 @@ 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 { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; +import { invalidateCommandsForProviderChange } from "@/agent-commands/cache"; import { shareCheckoutDiff } from "@/git/diff-sharing"; import { orderCheckoutDiffFiles } from "@/git/diff-order"; import { daemonConfigQueryKey } from "@/data/daemon-config"; @@ -186,10 +187,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<{ snapshotHash?: string }>(queryKey); + const previous = input.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(queryKey); let announcement: typeof snapshot | undefined = snapshot; void input.queryClient.cancelQueries({ queryKey, exact: true }); - await input.queryClient.fetchQuery({ + const current = await input.queryClient.fetchQuery({ queryKey, staleTime: 0, structuralSharing: false, @@ -205,12 +206,13 @@ export async function applyProvidersSnapshotUpdate(input: { }); }, }); - if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) { - void input.queryClient.invalidateQueries({ - queryKey: agentCommandsQueryRoot(input.serverId), - exact: false, - }); - } + void invalidateCommandsForProviderChange({ + queryClient: input.queryClient, + serverId: input.serverId, + cwd, + previous: previous?.entries, + current: current.entries, + }); } export function mountServerDataPushRouter(input: PushRouterInput): () => void { diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index 2a645f8666..e2b25d7b29 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -27,7 +27,7 @@ type ReplicaQueryInput type FetchQueryInput = Omit< UseQueryOptions, - "initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime" + "initialData" | "placeholderData" | "queryFn" | "staleTime" > & { dataShape: "list" | "value"; queryFn: QueryFnOption; @@ -113,7 +113,7 @@ export function fetchQueryOptions< dataShape, }, }, - refetchOnMount: immutableWhen ? true : "always", + refetchOnMount: options.refetchOnMount ?? (immutableWhen ? true : "always"), staleTime: immutableWhen ? (query) => query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0 diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts index 026ec9d43a..bbfeb6d758 100644 --- a/packages/app/src/git/checkout-status-cache.test.ts +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -16,7 +16,7 @@ import { resolveWorkingDiffComparison, selectWorkingDiffComparison, } from "@/git/working-diff-comparison"; -import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; +import { draftAgentCommandsQueryKey } from "@/agent-commands/cache"; import { applyCheckoutStatusUpdateFromEvent, ensureCheckoutStatus, diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index ca5cbb3a7a..4aaa650c70 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -8,7 +8,7 @@ import { invalidatePrPaneTimelineForCheckout, } from "@/git/query-keys"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; -import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query"; +import { resetDraftAgentCommandsForCheckout } from "@/agent-commands/cache"; export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; export type { CheckoutPrStatusPayload } from "@/git/pr-status"; diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index a30b27ce8f..41806de307 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -62,7 +62,9 @@ describe("ACP provider catalog", () => { expect(findProvider("minimax-code")).toMatchObject({ title: "MiniMax Code", version: "0.6.13", - command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"], + // The package publishes `mcode`, not `code`, so npx needs the package and + // the binary named separately. + command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"], }); expect(findProvider("minimax-code").iconSvg).toContain(" { - const module = await import("./use-agent-history"); - fetchAgentHistoryBatch = module.fetchAgentHistoryBatch; - fetchAgentHistoryPage = module.fetchAgentHistoryPage; - collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors; -}); +const { fetchAgentHistoryBatch, fetchAgentHistoryPage, collectAgentHistoryHostErrors } = + await import("./use-agent-history"); type FetchAgentHistory = DaemonClient["fetchAgentHistory"]; type FetchAgentHistoryResult = Awaited>; diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index 19caef4f8b..68102ed131 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -422,6 +422,23 @@ describe("deriveAgentScreenViewState", () => { expect(result.memory.lastReadyAgent).toBeNull(); }); + it.each(["error", "retrying"] as const)("shows archived history sync %s", (status) => { + const result = deriveAgentScreenViewState({ + input: { + ...createBaseInput(), + agent: createAgent("agent-1"), + isArchived: true, + hasHydratedHistoryBefore: true, + visibilityCatchUpStatus: status, + }, + memory: createBaseMemory(), + }); + expect(expectReadyState(result.state).sync).toEqual({ + status: "sync_error", + isRetrying: status === "retrying", + }); + }); + it("renders an archived agent before provider history is initialized", () => { const result = deriveAgentScreenViewState({ input: { diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 591fd1abb1..e0cc75ee06 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -175,10 +175,14 @@ function resolveAgentScreenSync(args: { hadInitialSyncFailure: boolean; }): AgentScreenReadySyncState { const { input, hadInitialSyncFailure } = args; - if (input.isArchived) { + if ( + input.isArchived && + input.visibilityCatchUpStatus !== "error" && + input.visibilityCatchUpStatus !== "retrying" + ) { return { status: "idle" }; } - if (!input.isConnected) { + if (!input.isConnected && !input.isArchived) { return { status: "reconnecting" }; } if (input.missingAgentState.kind === "error") { diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 2f3c90b340..0c730cddbd 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -1,4 +1,4 @@ -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { beforeEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -8,7 +8,7 @@ import { expandProviderSnapshot, } from "@getpaseo/protocol/provider-snapshot-codec"; import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; -import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; +import { draftAgentCommandsQueryKey, sessionAgentCommandsQueryKey } from "@/agent-commands/cache"; import { providerSnapshotIcons } from "@/data/provider-icons"; import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router"; import { @@ -363,6 +363,153 @@ describe("applyProvidersSnapshotUpdate", () => { }; } + it("refreshes active commands only when their provider changes after discovery", async () => { + const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" }); + let requests = 0; + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => { + requests++; + return [{ name: "review" }]; + }, + staleTime: Infinity, + meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" }, + }); + const unsubscribe = observer.subscribe(() => {}); + await observer.refetch({ cancelRefetch: false }); + expect(requests).toBe(1); + const codex = codexEntry("ready", [readyCodexModel]); + const other = { provider: "claude", status: "loading", enabled: true } as ProviderSnapshotEntry; + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codexEntry("loading"), other], "/repo-a"), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codex, other], "/repo-a"), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codex, { ...other, status: "ready" }], "/repo-a"), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage( + [ + { ...codex, fetchedAt: "2026-01-01T00:00:02.000Z" }, + { ...other, status: "ready" }, + ], + "/repo-a", + ), + }); + expect(requests).toBe(1); + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, "/repo-b"), + providersSnapshot([codex]), + ); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage( + [codexEntry("ready", [{ ...readyCodexModel, label: "Other checkout" }])], + "/repo-b", + ), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage( + [ + codexEntry("ready", [{ ...readyCodexModel, label: "Updated" }]), + { ...other, status: "ready" }, + ], + "/repo-a", + ), + }); + await observer.refetch({ cancelRefetch: false }); + expect(requests).toBe(2); + unsubscribe(); + queryClient.clear(); + }); + + it("retries a failed command discovery when its provider first becomes ready", async () => { + const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" }); + let ready = false; + let requests = 0; + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => { + requests++; + if (!ready) throw new Error("provider unavailable"); + return [{ name: "review" }]; + }, + retry: false, + meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" }, + }); + const unsubscribe = observer.subscribe(() => {}); + await observer.refetch({ cancelRefetch: false }); + expect(observer.getCurrentResult().isError).toBe(true); + ready = true; + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), + }); + await observer.refetch({ cancelRefetch: false }); + expect(requests).toBe(2); + expect(observer.getCurrentResult().data).toEqual([{ name: "review" }]); + unsubscribe(); + queryClient.clear(); + }); + + it("refreshes again when its provider changes during an active refresh", async () => { + const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" }); + queryClient.setQueryData(key, [{ name: "old" }]); + let requests = 0; + const waiting = new Promise<{ name: string }[]>(() => {}); + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => (++requests === 1 ? waiting : [{ name: "new" }]), + staleTime: Infinity, + meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" }, + }); + const unsubscribe = observer.subscribe(() => {}); + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, "/repo-a"), + providersSnapshot([codexEntry("ready", [readyCodexModel])]), + ); + const send = (label: string) => + applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [{ ...readyCodexModel, label }])], "/repo-a"), + }); + await send("First change"); + expect(requests).toBe(1); + await send("Second change"); + expect(requests).toBe(2); + await observer.refetch({ cancelRefetch: false }); + expect(observer.getCurrentResult().data).toEqual([{ name: "new" }]); + unsubscribe(); + queryClient.clear(); + }); + it("routes updates to the home query cache when the message carries no cwd", async () => { await applyProvidersSnapshotUpdate({ client: createClient(), @@ -455,6 +602,10 @@ describe("applyProvidersSnapshotUpdate", () => { draftConfig: { provider: "codex", cwd: "/repo-a" }, }); queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]); + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, "/repo-a"), + providersSnapshot([codexEntry("ready")]), + ); await applyProvidersSnapshotUpdate({ client: createClient(), diff --git a/packages/app/src/hosts/add-host/index.tsx b/packages/app/src/hosts/add-host/index.tsx new file mode 100644 index 0000000000..42b37c6d8e --- /dev/null +++ b/packages/app/src/hosts/add-host/index.tsx @@ -0,0 +1,89 @@ +import { createContext, useCallback, useContext, useState, useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { useStore } from "zustand"; +import { AddHostMethodModal } from "./internal/add-host-method-modal"; +import { AddHostModal } from "./internal/add-host-modal"; +import { AddRemoteSshHostModal } from "./internal/add-remote-ssh-host-modal"; +import { PairLinkModal } from "./internal/pair-link-modal"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { PairScanContent } from "./internal/scan"; +import { createAddHostFlow, type AddHostOptions, type AddHostSession } from "./internal/model"; + +const Context = createContext | null>(null); + +/** Owns the flow outside screen and appearance lifetimes. Callers own success navigation. */ +export function AddHostProvider({ children }: { children: ReactNode }) { + const [flow] = useState(createAddHostFlow); + return {children}; +} + +export function AddHostFlowHost() { + const flow = useContext(Context); + if (!flow) throw new Error("AddHostFlowHost requires AddHostProvider"); + const session = useStore(flow.store, (state) => state.session); + return session ? : null; +} + +export function useAddHost({ method, onAdded }: AddHostOptions = {}) { + const flow = useContext(Context); + if (!flow) throw new Error("useAddHost requires AddHostProvider"); + return useCallback(() => flow.open({ method, onAdded }), [flow, method, onAdded]); +} + +function AddHostSessionView({ session }: { session: AddHostSession }) { + const { t } = useTranslation(); + const method = useStore(session.step, (state) => state.method); + const back = useCallback(() => session.choose(null), [session]); + const direct = useCallback(() => session.choose("direct"), [session]); + const ssh = useCallback(() => session.choose("ssh"), [session]); + const link = useCallback(() => session.choose("link"), [session]); + const scan = useCallback(() => session.choose("scan"), [session]); + const scanned = useCallback((serverId: string) => session.complete({ serverId }), [session]); + const header = useMemo( + () => ({ title: t("pairing.scan.title"), back: { onPress: back } }), + [t, back], + ); + return ( + <> + + + + + {method === "scan" ? ( + + + + ) : null} + + ); +} +const SCAN_SNAP_POINTS = ["90%"]; diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/hosts/add-host/internal/add-host-method-modal.tsx similarity index 92% rename from packages/app/src/components/add-host-method-modal.tsx rename to packages/app/src/hosts/add-host/internal/add-host-method-modal.tsx index b97c529bef..35e61a5d17 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/hosts/add-host/internal/add-host-method-modal.tsx @@ -1,9 +1,10 @@ +import { PressHighlight } from "@/components/ui/press-highlight"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View } from "react-native"; +import { 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 "./adaptive-modal-sheet"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { isFdroidBuild } from "@/constants/build-profile"; import { isNative } from "@/constants/platform"; import { isElectronRuntime } from "@/desktop/host"; @@ -84,7 +85,7 @@ export function AddHostMethodModal({ onClose={onClose} testID="add-host-method-modal" > - - + {isElectronRuntime() ? ( - - + ) : null} {isNative && !isFdroidBuild ? ( - - + ) : null} - - + ); } diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/hosts/add-host/internal/add-host-modal.tsx similarity index 87% rename from packages/app/src/components/add-host-modal.tsx rename to packages/app/src/hosts/add-host/internal/add-host-modal.tsx index 653d69c4f1..5379e5f4bd 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/hosts/add-host/internal/add-host-modal.tsx @@ -1,20 +1,22 @@ +import { PressHighlight } from "@/components/ui/press-highlight"; import { useCallback, useMemo, useReducer, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, Pressable, Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Alert, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } 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 { - parseConnectionUri, - serializeConnectionUri, - serializeConnectionUriForStorage, -} from "@/utils/daemon-endpoints"; -import { buildConnectionFailureCopy } from "./connection-failure-copy"; -import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; -import { PairingTargetTracker } from "./pair-link-credentials"; +import { PairingTargetTracker } from "@/components/pair-link-credentials"; +import { buildDirectConnectionUri } from "@/components/direct-connection-host"; const FLEX_ONE_STYLE = { flex: 1 } as const; @@ -139,37 +141,11 @@ 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(buildConnectionUriFromDraft(draft, labels)); + const parsed = parseConnectionUri(buildDirectConnectionUri(draft, labels)); const endpoint = parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`; @@ -217,7 +193,6 @@ 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(); @@ -236,10 +211,7 @@ 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( - () => , - [theme.colors.accentForeground], - ); + const connectIcon = useMemo(() => , []); const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []); const portFieldStyle = useMemo(() => [styles.field, styles.portField], []); const checkboxStyle = useMemo( @@ -420,7 +392,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod advancedTarget.current = new PairingTargetTracker("", true); try { setAdvancedUri( - buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels), + buildDirectConnectionUri({ host, port, useTls, password }, directConnectionLabels), ); } catch { setAdvancedUri(""); @@ -444,8 +416,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod setIsAdvancedOpen(false); }, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]); - const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight; - const PasswordIcon = isPasswordVisible ? EyeOff : Eye; + const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight; + const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye; return ( {t("pairing.direct.fields.host")} - {t("pairing.direct.fields.port")} - - {useTls ? ( - + ) : null} {t("pairing.direct.fields.useSsl")} - + {t("pairing.direct.fields.password")} - - - - + + - - + {t("pairing.direct.advanced.label")} - + {isAdvancedOpen ? ( - ); } + +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, +})); diff --git a/packages/app/src/components/add-remote-ssh-host-modal.tsx b/packages/app/src/hosts/add-host/internal/add-remote-ssh-host-modal.tsx similarity index 97% rename from packages/app/src/components/add-remote-ssh-host-modal.tsx rename to packages/app/src/hosts/add-host/internal/add-remote-ssh-host-modal.tsx index c7e25bce64..b3d1cf7829 100644 --- a/packages/app/src/components/add-remote-ssh-host-modal.tsx +++ b/packages/app/src/hosts/add-host/internal/add-remote-ssh-host-modal.tsx @@ -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 "./adaptive-modal-sheet"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/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} /> - {isPasswordVisible ? : } - + diff --git a/packages/app/src/hosts/add-host/internal/model.test.ts b/packages/app/src/hosts/add-host/internal/model.test.ts new file mode 100644 index 0000000000..a878a5b41d --- /dev/null +++ b/packages/app/src/hosts/add-host/internal/model.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { createAddHostFlow } from "./model"; + +describe("add host flow", () => { + it("closes before notifying the consumer and ignores duplicate completion", () => { + const flow = createAddHostFlow(); + const added: string[] = []; + flow.open({ + onAdded: (serverId) => { + expect(flow.store.getState().session).toBeNull(); + added.push(serverId); + }, + }); + const session = flow.store.getState().session!; + session.choose("direct"); + session.complete({ serverId: "new-host" }); + session.complete({ serverId: "new-host" }); + expect(added).toEqual(["new-host"]); + }); + + it("cancels without a success action and accepts a fresh request", () => { + const flow = createAddHostFlow(); + const added: string[] = []; + flow.open({ onAdded: (id) => added.push(id) }); + const cancelled = flow.store.getState().session!; + cancelled.choose("scan"); + cancelled.close(); + expect(flow.store.getState().session).toBeNull(); + flow.open({ method: "link" }); + const current = flow.store.getState().session!; + cancelled.complete({ serverId: "late-result" }); + cancelled.close(); + expect(flow.store.getState().session).toBe(current); + expect(current.step.getState()).toEqual({ method: "link" }); + expect(added).toEqual([]); + current.complete({ serverId: "new-host" }); + expect(flow.store.getState().session).toBeNull(); + }); + + it("returns to method selection within the same request", () => { + const flow = createAddHostFlow(); + flow.open(); + const session = flow.store.getState().session!; + session.choose("ssh"); + session.choose(null); + expect(flow.store.getState().session).toBe(session); + expect(session.step.getState()).toEqual({ method: null }); + }); +}); diff --git a/packages/app/src/hosts/add-host/internal/model.ts b/packages/app/src/hosts/add-host/internal/model.ts new file mode 100644 index 0000000000..614d9dddc0 --- /dev/null +++ b/packages/app/src/hosts/add-host/internal/model.ts @@ -0,0 +1,47 @@ +import { createStore, type StoreApi } from "zustand/vanilla"; + +export type AddHostMethod = "direct" | "ssh" | "link" | "scan"; +export interface AddHostOptions { + method?: AddHostMethod; + onAdded?: (serverId: string) => void; +} + +export interface AddHostSession { + id: number; + step: StoreApi<{ method: AddHostMethod | null }>; + choose(method: AddHostMethod | null): void; + close(): void; + complete(result: { serverId: string }): void; +} + +export function createAddHostFlow() { + const store = createStore<{ session: AddHostSession | null }>(() => ({ session: null })); + let nextId = 0; + function createSession(options: AddHostOptions) { + const step = createStore<{ method: AddHostMethod | null }>(() => ({ + method: options.method ?? null, + })); + const session = { + id: ++nextId, + step, + choose(method: AddHostMethod | null) { + step.setState({ method }); + }, + close() { + if (store.getState().session === session) store.setState({ session: null }); + }, + complete({ serverId }: { serverId: string }) { + if (store.getState().session !== session) return; + session.close(); + options.onAdded?.(serverId); + }, + }; + return session; + } + return { + store, + open(options: AddHostOptions = {}) { + store.setState({ session: createSession(options) }); + }, + }; +} diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/hosts/add-host/internal/pair-link-modal.tsx similarity index 92% rename from packages/app/src/components/pair-link-modal.tsx rename to packages/app/src/hosts/add-host/internal/pair-link-modal.tsx index 8ed9cefba9..f817bdc5cf 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/hosts/add-host/internal/pair-link-modal.tsx @@ -1,16 +1,20 @@ import { useCallback, useMemo, useReducer, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } 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 "./adaptive-modal-sheet"; +import { + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection"; -import { PairingTargetTracker } from "./pair-link-credentials"; +import { PairingTargetTracker } from "@/components/pair-link-credentials"; import { Button } from "@/components/ui/button"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; @@ -100,7 +104,6 @@ function PairLinkModalContent({ onCancel, onSaved, }: PairLinkModalProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const daemons = useHosts(); const { beginLinkPairing } = useHostMutations(); @@ -126,10 +129,7 @@ function PairLinkModalContent({ resetPasswordInput(); }, []); - const pairIcon = useMemo( - () => , - [theme.colors.accentForeground], - ); + const pairIcon = useMemo(() => , []); const handleClose = useCallback(() => { if (isSaving) return; @@ -212,7 +212,7 @@ function PairLinkModalContent({ {t("pairing.link.label")} - {t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })} - ); } + +const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground })); + +const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({ + placeholderTextColor: theme.colors.foregroundMuted, +})); diff --git a/packages/app/src/hosts/pair-scan-model.test.ts b/packages/app/src/hosts/add-host/internal/pair-scan-model.test.ts similarity index 100% rename from packages/app/src/hosts/pair-scan-model.test.ts rename to packages/app/src/hosts/add-host/internal/pair-scan-model.test.ts diff --git a/packages/app/src/hosts/pair-scan-model.ts b/packages/app/src/hosts/add-host/internal/pair-scan-model.ts similarity index 100% rename from packages/app/src/hosts/pair-scan-model.ts rename to packages/app/src/hosts/add-host/internal/pair-scan-model.ts diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/hosts/add-host/internal/scan.tsx similarity index 71% rename from packages/app/src/app/pair-scan.tsx rename to packages/app/src/hosts/add-host/internal/scan.tsx index 5d352140ee..1d96e326df 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/hosts/add-host/internal/scan.tsx @@ -1,17 +1,14 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View } from "react-native"; -import { useLocalSearchParams, useRouter, type Href } from "expo-router"; +import { Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet } 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 "@/hosts/pair-scan-model"; -import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; +import { openPairScan } from "./pair-scan-model"; import { isWeb } from "@/constants/platform"; -import { BackHeader } from "@/components/headers/back-header"; -import { PairLinkModal } from "@/components/pair-link-modal"; +import { PairLinkModal } from "./pair-link-modal"; import { Button } from "@/components/ui/button"; const styles = StyleSheet.create((theme) => ({ @@ -113,50 +110,26 @@ const styles = StyleSheet.create((theme) => ({ }, })); -export default function PairScanScreen() { - const { theme } = useUnistyles(); +export function PairScanContent({ + onConnected, + onClose, +}: { + onConnected: (serverId: string) => void; + onClose: () => void; +}) { 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, - source === "onboarding" ? "hostRoot" : "hostSettings", - ), - onConnected: navigateToPairedHost, + importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"), + onConnected, }), ); 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; @@ -169,35 +142,29 @@ export default function PairScanScreen() { [scan], ); - const handleRouterBack = useCallback(() => router.back(), [router]); const savePasswordPairing = useCallback( - ({ serverId }: { serverId: string }) => navigateToPairedHost(serverId), - [navigateToPairedHost], + ({ serverId }: { serverId: string }) => onConnected(serverId), + [onConnected], ); const handleRequestPermission = useCallback(() => { void requestPermission(); }, [requestPermission]); const bodyStyle = useMemo( - () => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }], - [insets.bottom, theme.spacing], - ); - const helperTextStyle = useMemo( - () => [styles.helperText, { color: theme.colors.foreground }], - [theme.colors.foreground], + () => [styles.body, { paddingBottom: insets.bottom + 24 }], + [insets.bottom], ); if (isWeb) { return ( - {t("pairing.scan.webUnavailableTitle")} {t("pairing.scan.webUnavailableBody")} - - {t("pairing.scan.backToSettings")} - + @@ -208,16 +175,14 @@ export default function PairScanScreen() { return ( - - {!granted ? ( {t("pairing.scan.cameraPermissionTitle")} {t("pairing.scan.cameraPermissionBody")} - + ) : ( @@ -235,10 +200,10 @@ export default function PairScanScreen() { {scanState.status === "pairing" ? ( - {t("pairing.scan.pairing")} + {t("pairing.scan.pairing")} ) : null} {scanState.status === "stopped" && scanState.error ? ( - {scanState.error} + {scanState.error} ) : null} {scanState.status === "stopped" ? (