mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9e56e1eb9 | ||
|
|
3274d67959 | ||
|
|
2fbc5f4b59 | ||
|
|
833b4ef80a | ||
|
|
39f1058005 |
@@ -135,32 +135,6 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Backport electron-builder#10172 to the pinned build tool only.
|
||||
# Remove this step when app-builder-lib is upgraded to a release containing it.
|
||||
- name: Repair temporary keychain password in signing tool
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'KEYCHAIN_FIX'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const manifestPath = require.resolve('app-builder-lib/package.json');
|
||||
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
|
||||
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
|
||||
}
|
||||
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
|
||||
let source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const replacements = [
|
||||
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
|
||||
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
|
||||
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
|
||||
];
|
||||
for (const [before, after] of replacements) {
|
||||
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
|
||||
source = source.replace(before, after);
|
||||
}
|
||||
fs.writeFileSync(sourcePath, source);
|
||||
KEYCHAIN_FIX
|
||||
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
+6
-96
@@ -1,105 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.2 - 2026-11-05
|
||||
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
|
||||
|
||||
### Added
|
||||
Added:
|
||||
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 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))
|
||||
|
||||
### Improved
|
||||
Improved:
|
||||
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Replaced the "Loading…" text in Providers settings with skeleton rows ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Changed controls to two sizes: default controls are 32px tall on desktop and 44px on phones, and compact controls are 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Changed first launch to go from the loading screen straight into setup instead of showing the empty home first ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||
- Changed dialogs on desktop and web to fade in over about 160ms ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||
- Changed modal and sheet backgrounds to match the screen background ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Changed documentation links in Settings sections to a `Label ↗` link beside the section title ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
|
||||
## 0.15.1 - 2026-11-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run `Get-CimInstance` ([#8810](https://github.com/getpaseo/paseo/pull/8810))
|
||||
|
||||
## 0.15.0 - 2026-11-03
|
||||
|
||||
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||
|
||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||
|
||||
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||
|
||||
### 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))
|
||||
- Added an opt-in setting to resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127), [#8619](https://github.com/getpaseo/paseo/pull/8619) 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 chat reconnecting and updating status being hidden behind the header on iOS and Android phones ([#8568](https://github.com/getpaseo/paseo/pull/8568))
|
||||
- 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
|
||||
- Agent recovery is off by default; turn on **Resume agents after restart** in Host settings → Agents to send `Resume working.` or your own prompt
|
||||
- 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
-97
@@ -1,105 +1,15 @@
|
||||
# Changelog
|
||||
# 更新日志
|
||||
|
||||
## 0.15.2 - 2026-11-05
|
||||
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
|
||||
|
||||
### 新增
|
||||
新增:
|
||||
|
||||
- 在 Paseo 桌面版和网页版新增首次运行引导,依次带新用户设置主题、提供商、项目、Agent 编排和移动端应用,并可通过“设置 → 通用 → Show setup again”重新打开 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 在引导的“项目”步骤中,根据你运行过 Claude Code、Codex、OpenCode 或 Pi 的文件夹推荐项目 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 在 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))
|
||||
|
||||
### 改进
|
||||
改进:
|
||||
|
||||
- 将主题选择器改为带各主题预览的图块网格 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 将“提供商”设置中的“Loading…”文字替换为骨架占位行 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 将控件统一为两种尺寸:默认控件在桌面端高 32px、在手机上高 44px,紧凑控件高 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 将首次启动改为从加载界面直接进入引导,不再先显示空白的主页 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||
- 将桌面端和网页版的对话框改为约 160 毫秒的淡入 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||
- 将弹窗和面板的背景改为与屏幕背景一致 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 将“设置”各分区的文档链接改为分区标题旁的 `Label ↗` 链接 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
|
||||
## 0.15.1 - 2026-11-05
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复终端工作进程无法读取自身进程标识时 daemon 无法启动的问题;此前在 Windows PowerShell 5.1 无法运行 `Get-CimInstance` 的 Windows 机器上,Paseo 桌面版因此没有可用的 daemon ([#8810](https://github.com/getpaseo/paseo/pull/8810))
|
||||
|
||||
## 0.15.0 - 2026-11-03
|
||||
|
||||
daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。
|
||||
|
||||
移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
|
||||
|
||||
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), [#8619](https://github.com/getpaseo/paseo/pull/8619),贡献者:[@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))
|
||||
- 修复了 iOS 和 Android 手机聊天中重新连接和更新状态被顶栏遮挡的问题 ([#8568](https://github.com/getpaseo/paseo/pull/8568))
|
||||
- 修复了删除 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 恢复默认关闭;在主机设置 → Agent 中开启 **重启后恢复智能体**,即可发送 `Resume working.` 或自定义提示词
|
||||
- 降级到 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
|
||||
|
||||
|
||||
@@ -14,10 +14,9 @@ Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idl
|
||||
|
||||
## Daemon restart recovery
|
||||
|
||||
Restart recovery is off by default. Enable `agents.resumeOnStartup` in host config or
|
||||
Host settings → Agents to resume interrupted agents after graceful shutdown or a crash. The daemon
|
||||
reopens the saved provider conversation and settings, then sends `Resume working.`. Customize
|
||||
the prompt with `agents.resumePrompt` or the same settings page.
|
||||
Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon
|
||||
reopens the saved provider conversation and settings, then sends `Resume working.`. Change
|
||||
`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents.
|
||||
Disabling recovery consumes pending intent; enabling it later does not revive old work.
|
||||
|
||||
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
|
||||
|
||||
@@ -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.
|
||||
|
||||
+9
-9
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
@@ -36,7 +36,7 @@ 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/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`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
|
||||
- **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.
|
||||
|
||||
@@ -68,9 +68,9 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px in the compact layout, so every phone control is a full touch target; the breakpoint, not the caller, makes that choice. `sm` is 28px with `fontSize.sm` labels and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 in the compact layout, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
@@ -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/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. Both sit on `surface0`, the same base as a screen, so settings cards and inputs inside a modal read the way they do on the Settings screen. 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.
|
||||
|
||||
@@ -126,7 +126,7 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
|
||||
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `xs` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
|
||||
|
||||
The whitespace is the design.
|
||||
|
||||
@@ -197,11 +197,11 @@ Terminology:
|
||||
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
|
||||
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `sm` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
|
||||
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
|
||||
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||
|
||||
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||
|
||||
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||
|
||||
@@ -251,7 +251,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
|
||||
## 14. Forbidden
|
||||
|
||||
- `fontWeight.medium` on row titles, body text, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
|
||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||
|
||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||
|
||||
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||
|
||||
Added
|
||||
- Insert known agent commands from replies into the Composer draft
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,10 +0,0 @@
|
||||
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||
|
||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||
|
||||
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||
|
||||
Added
|
||||
- Insert known agent commands from replies into the Composer draft
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,10 +0,0 @@
|
||||
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||
|
||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||
|
||||
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||
|
||||
Added
|
||||
- Insert known agent commands from replies into the Composer draft
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,10 +0,0 @@
|
||||
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||
|
||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||
|
||||
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||
|
||||
Added
|
||||
- Insert known agent commands from replies into the Composer draft
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Added
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||
|
||||
Improved
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Added
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||
|
||||
Improved
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Added
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||
|
||||
Improved
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Added
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||
|
||||
Improved
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-YLrbUckIiyV7esu2tmiKdLL0YHsvwqcA0Xe0gSeMCC0=
|
||||
sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw=
|
||||
|
||||
Generated
+73
-74
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -14740,52 +14740,16 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-image": {
|
||||
"version": "0.10.0-beta.331",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.331.tgz",
|
||||
"integrity": "sha512-ShBTb3QF0ul/i55lfRdJpcu0hlA4aJdqRdzzHdOCws2WUzSsG0SCfMhLjzaoO1dVUGNj2Z7PNLqiXnlyqOinbg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.336"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-serialize": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
|
||||
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-unicode-graphemes": {
|
||||
"version": "0.5.0-beta.330",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode-graphemes/-/addon-unicode-graphemes-0.5.0-beta.330.tgz",
|
||||
"integrity": "sha512-Zoiv/8tmku4LcU7bEL6zer+DNN6p6F8VCkyviSmbDxpA/W9zSnWDiD/ZpwFDQqegwjSptGzBNMhW2Os1sa+lUw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.336"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.20.0-beta.329",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.329.tgz",
|
||||
"integrity": "sha512-bwqMma7X4cCBfwmWqKuhxNLkIXKW0lJTapaQ0g/9Kq7n6D6VCL4dYQCQ6DoeHv27ncJD4WzeoDTCEeSn0cspfg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.336"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/headless": {
|
||||
"version": "6.1.0-beta.334",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.334.tgz",
|
||||
"integrity": "sha512-QkwuIeP9dXlk/eOsAbqX8AvtS/W8EhRo6dkl2YYZr+jWAWSfDDMi+zPlegWUflSdsRKMURMvzvvkVZr6GztK4g==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.1.0-beta.336",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.336.tgz",
|
||||
"integrity": "sha512-QrvZZsffXsLW4Z3sba9GqimLvVwlb8b3W1NwZ+l6DdRnpH5pg5cFGWg2ceIburmfaGQ07YJHNekwxxlTcHZ3BA==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
|
||||
"integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
@@ -37632,7 +37596,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37662,14 +37626,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",
|
||||
@@ -37747,7 +37710,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",
|
||||
@@ -38526,6 +38489,15 @@
|
||||
"@xterm/xterm": "^6.1.0-beta.213"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/@xterm/addon-image": {
|
||||
"version": "0.10.0-beta.213",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.213.tgz",
|
||||
"integrity": "sha512-L3QxLkj8g8TioVea9p1oVze52Jr75rRZPPLm1tgZl0X2EGJGFHnQzE3QRsgmYWI7R5j3kwwi3MRAkOAgvOK8uA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.213"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/@xterm/addon-ligatures": {
|
||||
"version": "0.11.0-beta.213",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.213.tgz",
|
||||
@@ -38569,6 +38541,34 @@
|
||||
"@xterm/xterm": "^6.1.0-beta.213"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.20.0-beta.291",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.291.tgz",
|
||||
"integrity": "sha512-gH7r5g8f2NWMhBtaeApuwLQTJmX4uq7zT0okjeNWkFAD0nxzbUPB+kt6sogZZ5K90XYPt2OLu/bhuvF6DmKqOQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.292"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/@xterm/headless": {
|
||||
"version": "6.1.0-beta.213",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.213.tgz",
|
||||
"integrity": "sha512-D08E4Bl0HRhYZRL2o5PxD4FNQN9qXpI8QSw3CkKrsym5dtTRAqsynVYE8V8echQrxfb+EpR0CB5reM7tEGZ/fA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"packages/app/node_modules/@xterm/xterm": {
|
||||
"version": "6.1.0-beta.292",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.292.tgz",
|
||||
"integrity": "sha512-17zqK5tM/l6qeD7McF42OrEJ6w3XqJ2vFVKdWqu0cYLzdFqMWAHp2oFNc8Fj5DmqDSl1E1FZEg6IFflDllTvLA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"packages/app/node_modules/babel-plugin-syntax-hermes-parser": {
|
||||
"version": "0.36.1",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz",
|
||||
@@ -39800,12 +39800,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/server": "0.15.2",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/server": "0.14.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -40052,10 +40052,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/relay": "0.15.2",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/relay": "0.14.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -40067,7 +40067,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40311,7 +40311,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40357,7 +40357,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -40386,15 +40386,15 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40404,8 +40404,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40427,7 +40427,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40454,7 +40454,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
@@ -40675,23 +40675,22 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/highlight": "0.15.2",
|
||||
"@getpaseo/plugin": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/relay": "0.15.2",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/highlight": "0.14.0",
|
||||
"@getpaseo/plugin": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/relay": "0.14.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@opencode/client": "2.0.10",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
|
||||
"@xterm/headless": "6.1.0-beta.334",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.20.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -41631,7 +41630,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43241,7 +43240,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.2",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -9,11 +9,9 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole("radiogroup", { name: "Theme" }).getByRole("radio").first(),
|
||||
).toHaveAccessibleName("System");
|
||||
await expect(page.getByRole("radio", { name: "Pure black", exact: true })).toBeVisible();
|
||||
const themeTrigger = page.getByLabel("Theme: System", { exact: true });
|
||||
await themeTrigger.click();
|
||||
await expect(page.getByText("Pure black", { exact: true })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("appearance-theme-picker.png"),
|
||||
fullPage: true,
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, metroTest as test } from "../support/fixtures";
|
||||
import {
|
||||
addProjectFromSetup,
|
||||
continueSetup,
|
||||
expectSetupStep,
|
||||
finishSetup,
|
||||
goToSetupStep,
|
||||
hostGivesAgentsPaseoTools,
|
||||
openAppAsNewUser,
|
||||
openRelayPairingFromSetup,
|
||||
closeRelayPairing,
|
||||
emptyHome,
|
||||
loadingScreen,
|
||||
openTailscaleDocsFromSetup,
|
||||
paseoToolsSwitch,
|
||||
pickTheme,
|
||||
projectSuggestion,
|
||||
projectSuggestionRow,
|
||||
readFirstLaunch,
|
||||
recordFirstLaunch,
|
||||
setupModal,
|
||||
setupPrimaryButton,
|
||||
showSetupAgainFromSettings,
|
||||
skipSetup,
|
||||
startSetupHost,
|
||||
} from "../support/helpers/onboarding";
|
||||
import {
|
||||
chooseCustomSkills,
|
||||
openSkillSelection,
|
||||
saveSkillSelection,
|
||||
} from "../support/helpers/agent-skills";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { enableRelayAndExpectOffer } from "../support/helpers/pair-device";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
// Midnight's muted foreground (#9a9db0), which the step description is drawn in.
|
||||
const MIDNIGHT_MUTED_FOREGROUND = "rgb(154, 157, 176)";
|
||||
|
||||
test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
const host = await startSetupHost("fresh");
|
||||
const repo = await createTempGitRepo("onboarding-folder-");
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
|
||||
await test.step("setup opens on Appearance and a theme applies at once", async () => {
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
await pickTheme(page, "Midnight");
|
||||
await expect(
|
||||
setupModal(page).getByText("Let's start by picking a look", { exact: true }),
|
||||
).toHaveCSS("color", MIDNIGHT_MUTED_FOREGROUND);
|
||||
await page.screenshot({ path: testInfo.outputPath("1-appearance.png") });
|
||||
});
|
||||
|
||||
await test.step("Providers shows the same provider list as Settings", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Your agents");
|
||||
await expect(setupModal(page).getByTestId("host-page-providers-card")).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("2-providers.png") });
|
||||
});
|
||||
|
||||
await test.step("with no agent history, Add project opens the regular flow and the folder shows as added", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
await expect(
|
||||
setupModal(page).getByText("No recent projects found", { exact: true }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await addProjectFromSetup(page, repo.path);
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
await expect(setupModal(page).getByText(repo.path, { exact: true })).toBeVisible();
|
||||
await expect(setupModal(page).getByText("Added", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("3-projects.png") });
|
||||
});
|
||||
|
||||
await test.step("Orchestration starts with its switches on", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Let agents use Paseo");
|
||||
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
|
||||
await expect(
|
||||
setupModal(page).getByRole("switch", { name: "Orchestration skills", exact: true }),
|
||||
).toBeChecked();
|
||||
await page.screenshot({ path: testInfo.outputPath("4-orchestration.png") });
|
||||
});
|
||||
|
||||
await test.step("Skip setup closes it for good", async () => {
|
||||
await skipSetup(page);
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-project-row-"]').filter({
|
||||
hasText: path.basename(repo.path),
|
||||
}),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
});
|
||||
} finally {
|
||||
await host.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new user goes from the loading screen straight into setup", async ({ page }) => {
|
||||
const host = await startSetupHost("first-launch");
|
||||
|
||||
try {
|
||||
await recordFirstLaunch(page);
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
|
||||
const launch = await readFirstLaunch(page);
|
||||
expect(launch.appBeforeSetup).toBe(false);
|
||||
expect(launch.setupOpacityOnArrival).toBeLessThan(1);
|
||||
await expect(setupModal(page)).toHaveCSS("opacity", "1");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("with reduced motion, setup appears without fading in", async ({ page }) => {
|
||||
const host = await startSetupHost("reduced-motion");
|
||||
|
||||
try {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await recordFirstLaunch(page);
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
|
||||
expect((await readFirstLaunch(page)).setupOpacityOnArrival).toBe(1);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("changing the theme keeps the setup window in place on every step", async ({ page }) => {
|
||||
const host = await startSetupHost("theme-change");
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
const setupWindow = await setupModal(page).elementHandle();
|
||||
|
||||
for (const title of [
|
||||
"Welcome to Paseo",
|
||||
"Your agents",
|
||||
"Add your projects",
|
||||
"Let agents use Paseo",
|
||||
"Take your agents with you",
|
||||
]) {
|
||||
if (title !== "Welcome to Paseo") await continueSetup(page);
|
||||
await expectSetupStep(page, title);
|
||||
const heading = setupModal(page).getByRole("heading", { name: title });
|
||||
const headingNode = await heading.elementHandle();
|
||||
const lightColor = await heading.evaluate((node) => getComputedStyle(node).color);
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await expect(heading).not.toHaveCSS("color", lightColor);
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await expect(heading).toHaveCSS("color", lightColor);
|
||||
expect(await setupWindow?.evaluate((node) => node.isConnected), title).toBe(true);
|
||||
expect(await headingNode?.evaluate((node) => node.isConnected), title).toBe(true);
|
||||
}
|
||||
|
||||
await goToSetupStep(page, "Appearance");
|
||||
await pickTheme(page, "Midnight");
|
||||
await pickTheme(page, "Light");
|
||||
expect(await setupWindow?.evaluate((node) => node.isConnected)).toBe(true);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the loading screen stays up while the setup host loads its workspaces", async ({ page }) => {
|
||||
const host = await startSetupHost("slow-workspaces");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
|
||||
try {
|
||||
await recordFirstLaunch(page);
|
||||
gate.holdNextClientRequest("fetch_workspaces_request");
|
||||
await openAppAsNewUser(page, host);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(loadingScreen(page)).toBeVisible();
|
||||
await expect(emptyHome(page)).toHaveCount(0);
|
||||
|
||||
gate.releaseHeldClientRequest();
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
expect((await readFirstLaunch(page)).appBeforeSetup).toBe(false);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a setup host that never loads its workspaces still lands on the home", async ({ page }) => {
|
||||
const host = await startSetupHost("stuck-workspaces");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
|
||||
try {
|
||||
gate.holdNextClientRequest("fetch_workspaces_request");
|
||||
await openAppAsNewUser(page, host);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
|
||||
// Setup still opens once the host answers.
|
||||
gate.releaseHeldClientRequest();
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a setup host that drops while loading still lands on the home", async ({ page }) => {
|
||||
const host = await startSetupHost("dropped-workspaces");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
|
||||
try {
|
||||
gate.holdNextClientRequest("fetch_workspaces_request");
|
||||
await openAppAsNewUser(page, host);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await gate.drop();
|
||||
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an unreachable setup host lands on the home without setup", async ({ page }) => {
|
||||
const host = await startSetupHost("unreachable");
|
||||
await host.close();
|
||||
|
||||
await openAppAsNewUser(page, host);
|
||||
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => {
|
||||
const repo = await createTempGitRepo("onboarding-history-");
|
||||
const repoName = path.basename(repo.path);
|
||||
const subdirectory = path.join(repo.path, "packages", "web");
|
||||
await mkdir(subdirectory, { recursive: true });
|
||||
const worktree = `${repo.path}-fix`;
|
||||
execFileSync("git", ["worktree", "add", "-b", "fix", worktree], { cwd: repo.path });
|
||||
const scratch = await createTempDirectory("onboarding-scratch-");
|
||||
const scratchName = path.basename(scratch.path);
|
||||
const host = await startSetupHost("history", {
|
||||
claudeSessionsIn: [repo.path, subdirectory, worktree, scratch.path, scratch.path, scratch.path],
|
||||
withBrokenProvider: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
await goToSetupStep(page, "Projects");
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
|
||||
await test.step("the repo, its subfolder and its worktree become one checked row", async () => {
|
||||
await expect(projectSuggestion(page, repoName)).toBeChecked({ timeout: 60_000 });
|
||||
await expect(projectSuggestion(page, scratchName)).toBeChecked();
|
||||
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
|
||||
});
|
||||
|
||||
await test.step("a provider that cannot read its history adds nothing and shows no error", async () => {
|
||||
await expect(setupModal(page).getByTestId("project-suggestions-skeleton")).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(setupModal(page).getByText(/Broken ACP/)).toHaveCount(0);
|
||||
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
|
||||
});
|
||||
|
||||
await test.step("the footer adds what is checked; the Add project flow is separate", async () => {
|
||||
await expect(setupPrimaryButton(page, "Add 2 projects")).toBeVisible();
|
||||
await expect(setupModal(page).getByRole("button", { name: "Continue" })).toHaveCount(0);
|
||||
await expect(
|
||||
setupModal(page).getByRole("button", { name: "Add project", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("8-project-suggestions.png") });
|
||||
await projectSuggestion(page, repoName).click();
|
||||
await projectSuggestion(page, scratchName).click();
|
||||
await expect(setupPrimaryButton(page, "Continue")).toBeVisible();
|
||||
await projectSuggestion(page, repoName).click();
|
||||
await projectSuggestion(page, scratchName).click();
|
||||
});
|
||||
|
||||
await test.step("a folder that cannot be added keeps the user on the step", async () => {
|
||||
await scratch.cleanup();
|
||||
await setupPrimaryButton(page, "Add 2 projects").click();
|
||||
await expect(setupPrimaryButton(page, "Add 1 project")).toBeVisible({ timeout: 30_000 });
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
await expect(projectSuggestion(page, scratchName)).toBeChecked();
|
||||
await expect(projectSuggestionRow(page, scratchName)).toContainText("Directory not found");
|
||||
await expect(projectSuggestion(page, repoName)).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("9-project-suggestion-failed.png") });
|
||||
});
|
||||
|
||||
await test.step("with nothing checked, Continue moves on without adding", async () => {
|
||||
await projectSuggestion(page, scratchName).click();
|
||||
await setupPrimaryButton(page, "Continue").click();
|
||||
await expectSetupStep(page, "Let agents use Paseo");
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: repoName }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: scratchName }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
} finally {
|
||||
await host.close();
|
||||
await rm(worktree, { recursive: true, force: true });
|
||||
await repo.cleanup();
|
||||
await scratch.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an existing user only sees setup when they ask for it", async ({ page }) => {
|
||||
const host = await startSetupHost("existing");
|
||||
const workspace = await seedWorkspace({ repoPrefix: "onboarding-existing-", port: host.port });
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${host.serverId}:${workspace.workspaceId}`),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
|
||||
await showSetupAgainFromSettings(page);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
await expect(setupModal(page).getByRole("tab", { name: "Appearance" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("setup choices carry over to Settings and the phone step pairs over relay", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const host = await startSetupHost("phone", { mutableRelay: { enabled: false } });
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
|
||||
await test.step("Paseo tools start on in setup and Continue turns them on", async () => {
|
||||
await goToSetupStep(page, "Orchestration");
|
||||
await expectSetupStep(page, "Let agents use Paseo");
|
||||
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
|
||||
await continueSetup(page);
|
||||
});
|
||||
|
||||
await test.step("Phone offers Tailscale docs and pairs over relay in its own modal", async () => {
|
||||
await expectSetupStep(page, "Take your agents with you");
|
||||
await page.screenshot({ path: testInfo.outputPath("5-phone.png") });
|
||||
expect(await openTailscaleDocsFromSetup(page)).toBe(
|
||||
"https://paseo.sh/docs/connectivity#tailscale",
|
||||
);
|
||||
const pairing = await openRelayPairingFromSetup(page);
|
||||
await expect(pairing.getByText("Enable relay?", { exact: true })).toBeVisible();
|
||||
await enableRelayAndExpectOffer(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("6-phone-qr.png") });
|
||||
await closeRelayPairing(pairing);
|
||||
});
|
||||
|
||||
await test.step("Finish closes setup and Settings shows the same Paseo tools value", async () => {
|
||||
await finishSetup(page);
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await openSettingsHostSection(page, host.serverId, "agents");
|
||||
await expect(paseoToolsSwitch(page)).toBeChecked();
|
||||
await page.screenshot({ path: testInfo.outputPath("7-settings-agents.png") });
|
||||
});
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("with no skills picked, skills read as off and Continue moves on", async ({ page }) => {
|
||||
const host = await startSetupHost("no-skills");
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
await goToSetupStep(page, "Orchestration");
|
||||
await expectSetupStep(page, "Let agents use Paseo");
|
||||
|
||||
await openSkillSelection(page);
|
||||
await chooseCustomSkills(page, []);
|
||||
await saveSkillSelection(page);
|
||||
const skillsSwitch = setupModal(page).getByRole("switch", {
|
||||
name: "Orchestration skills",
|
||||
exact: true,
|
||||
});
|
||||
await expect(skillsSwitch).not.toBeChecked();
|
||||
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Take your agents with you");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("in the compact layout, Finish on Orchestration applies its switches", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const host = await startSetupHost("compact");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
// The compact sheet is the only surface on screen, and Orchestration is its last step.
|
||||
const step = (title: string) =>
|
||||
expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
const primary = (label: string) => page.getByRole("button", { name: label, exact: true });
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await step("Welcome to Paseo");
|
||||
await primary("Continue").click();
|
||||
await step("Your agents");
|
||||
await primary("Continue").click();
|
||||
await step("Add your projects");
|
||||
await primary("Continue").click();
|
||||
|
||||
// The setup host starts with Paseo tools off; the switch starts on.
|
||||
expect(await hostGivesAgentsPaseoTools(host)).toBe(false);
|
||||
await step("Let agents use Paseo");
|
||||
await expect(paseoToolsSwitch(page)).toBeChecked();
|
||||
// Skills are not installed, so leaving them off has nothing to apply.
|
||||
const skillsSwitch = page.getByRole("switch", { name: "Orchestration skills", exact: true });
|
||||
await skillsSwitch.click();
|
||||
await expect(skillsSwitch).not.toBeChecked();
|
||||
gate.holdNextClientRequest("set_daemon_config_request");
|
||||
await primary("Finish").click();
|
||||
|
||||
// While the choices apply, none of them can change.
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(paseoToolsSwitch(page)).toBeDisabled();
|
||||
await expect(skillsSwitch).toBeDisabled();
|
||||
gate.releaseHeldClientRequest();
|
||||
|
||||
await expect.poll(() => hostGivesAgentsPaseoTools(host), { timeout: 30_000 }).toBe(true);
|
||||
await expect(page.getByRole("heading", { name: "Let agents use Paseo" })).toHaveCount(0);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
@@ -68,7 +69,8 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||
const mochaItem = page.getByRole("radio", { name: "Catppuccin Mocha", exact: true });
|
||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||
const mochaItem = page.getByText("Catppuccin Mocha", { exact: true });
|
||||
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-picker.png"),
|
||||
@@ -77,14 +79,14 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
});
|
||||
|
||||
await test.step("a contributed light theme uses the light palette", async () => {
|
||||
const latteItem = page.getByRole("radio", { name: "Catppuccin Latte", exact: true });
|
||||
await latteItem.click();
|
||||
await expect(latteItem).toBeChecked();
|
||||
await page.getByText("Catppuccin Latte", { exact: true }).click();
|
||||
await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible();
|
||||
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
|
||||
await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click();
|
||||
});
|
||||
|
||||
await mochaItem.click();
|
||||
await expect(mochaItem).toBeChecked();
|
||||
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible();
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-applied.png"),
|
||||
@@ -93,13 +95,15 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
|
||||
await test.step("the selection survives a reload", async () => {
|
||||
await page.reload();
|
||||
await expect(mochaItem).toBeChecked({ timeout: 30_000 });
|
||||
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
});
|
||||
|
||||
await test.step("removing the plugin falls back to the default theme", async () => {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked({
|
||||
await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
@@ -143,7 +147,15 @@ const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
test("reaches the last theme when a plugin contributes more themes than fit", async ({
|
||||
async function scrollThemeMenuToLastTheme(page: Page) {
|
||||
const firstTheme = page.getByText("Pack theme 1", { exact: true });
|
||||
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
|
||||
await firstTheme.hover();
|
||||
await page.mouse.wheel(0, 4000);
|
||||
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
|
||||
@@ -163,16 +175,15 @@ test("reaches the last theme when a plugin contributes more themes than fit", as
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
const lastTheme = page.getByRole("radio", { name: LAST_THEME_NAME, exact: true });
|
||||
await expect(lastTheme).toBeAttached({ timeout: 30_000 });
|
||||
await lastTheme.scrollIntoViewIfNeeded();
|
||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||
await scrollThemeMenuToLastTheme(page);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
|
||||
await lastTheme.click();
|
||||
await expect(lastTheme).toBeChecked();
|
||||
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
|
||||
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
|
||||
@@ -257,15 +257,12 @@ test.describe("Settings host page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("restart recovery defaults off and persists opt-in and custom prompt", async ({ page }) => {
|
||||
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "agents");
|
||||
await expectRestartRecoveryEnabled(page, false);
|
||||
await setRestartRecoveryEnabled(page, true);
|
||||
await page.reload();
|
||||
await expectRestartRecoveryEnabled(page, true);
|
||||
await expectRestartRecoveryPrompt(page, "Resume working.");
|
||||
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||
|
||||
@@ -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);
|
||||
@@ -326,12 +313,12 @@ test.describe("Settings sidebar navigation", () => {
|
||||
|
||||
await test.step("a dropdown owns Escape", async () => {
|
||||
await openSettingsSection(page, "appearance");
|
||||
await page.getByLabel(/Highlight theme:/).click();
|
||||
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toBeVisible();
|
||||
await page.getByLabel(/Theme:/).click();
|
||||
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -38,9 +38,12 @@ async function expectDarkIcons(expected, context) {
|
||||
assert.equal(windowState(), expected, context);
|
||||
}
|
||||
|
||||
// Settings > Appearance shows every theme as a tile in one radio group.
|
||||
function selectTheme(theme) {
|
||||
const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
|
||||
assert(current, "Start on Settings > Appearance");
|
||||
ad("press", `label="Theme: ${current}"`, "--settle");
|
||||
ad("press", `label="${theme}"`, "--settle");
|
||||
ad("wait", "text", `Theme: ${theme}`, "5000");
|
||||
}
|
||||
|
||||
ad("settings", "appearance", "light");
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// Open an existing compact chat, with the keyboard closed, then interrupt its host connection.
|
||||
// Hold that interruption externally (for example, stop a disposable SSH TCP forward).
|
||||
// Leave the daemon running. This test observes the public reconnecting state.
|
||||
// node placement.mjs <session> <platform> <coordinate-scale> <composer-label> <artifacts>
|
||||
// coordinate-scale is screenshot pixels per layout point (Android density; iOS snapshots use 1).
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [, , session, platform, coordinateScale, composerLabel, artifacts] = process.argv;
|
||||
assert(
|
||||
session && ["android", "ios"].includes(platform) && composerLabel && artifacts,
|
||||
"Pass all five arguments",
|
||||
);
|
||||
const scale = Number(coordinateScale);
|
||||
assert(scale > 0, "Pass a positive coordinate scale");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, "--platform", platform, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
function capture(name) {
|
||||
const snapshot = ad("snapshot", "--raw", "--json");
|
||||
writeFileSync(path.join(artifacts, `${name}.json`), snapshot);
|
||||
ad("screenshot", path.join(artifacts, `${name}.png`));
|
||||
const nodes = JSON.parse(snapshot).data.nodes;
|
||||
const header = nodes.find((node) => node.identifier === "floating-header");
|
||||
const toast = nodes.find((node) => node.identifier === "agent-reconnecting-toast");
|
||||
assert(header?.rect && toast?.rect, "Measured header and reconnecting toast must be present");
|
||||
assert(toast.rect.height > 0 && toast.rect.width > 0, "Toast must have visible bounds");
|
||||
const gap = (toast.rect.y - header.rect.y - header.rect.height) / scale;
|
||||
assert(Math.abs(gap - 12) <= 1, `${name}: actual header-to-toast gap ${gap}, expected12 points`);
|
||||
return toast.rect.y / scale;
|
||||
}
|
||||
|
||||
ad("wait", 'id="agent-reconnecting-toast"', "20000");
|
||||
const closedTop = capture("keyboard-closed");
|
||||
ad("press", `label=${JSON.stringify(composerLabel)}`);
|
||||
const opened = ad("snapshot", "--raw", "--json");
|
||||
const nodes = JSON.parse(opened).data.nodes;
|
||||
assert(
|
||||
nodes.some(
|
||||
(node) => /keyboard/i.test(node.type ?? "") || /inputmethod/i.test(node.bundleId ?? ""),
|
||||
),
|
||||
"Use a real visible software keyboard; open Android sessions with --no-test-ime",
|
||||
);
|
||||
const openTop = capture("keyboard-open");
|
||||
assert(Math.abs(openTop - closedTop) <= 1, "Keyboard must not translate the toast");
|
||||
console.log(
|
||||
"PASS: rendered reconnecting toast stays12 points below measured header, keyboard closed/open",
|
||||
);
|
||||
@@ -107,8 +107,6 @@ const daemonTest = metroTest.extend<
|
||||
});
|
||||
|
||||
const test = daemonTest.extend<{
|
||||
/** Specs start past first-run setup; onboarding specs opt out to see a fresh install. */
|
||||
e2eOnboardingCompleted: boolean;
|
||||
paseoE2ESetup: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
@@ -116,9 +114,8 @@ const test = daemonTest.extend<{
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
e2eOnboardingCompleted: [true, { option: true }],
|
||||
paseoE2ESetup: [
|
||||
async ({ page, e2eOnboardingCompleted }, provide, testInfo) => {
|
||||
async ({ page }, provide, testInfo) => {
|
||||
const daemonPort = getE2EDaemonPort();
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -160,7 +157,7 @@ const test = daemonTest.extend<{
|
||||
const createAgentPreferences = buildCreateAgentPreferences();
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey, onboardingCompleted }) => {
|
||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
@@ -188,20 +185,12 @@ const test = daemonTest.extend<{
|
||||
if (!localStorage.getItem("@paseo:app-settings")) {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
|
||||
}
|
||||
// The shared daemon often has no projects, which would open first-run setup over the app.
|
||||
if (onboardingCompleted && !localStorage.getItem("@paseo:onboarding")) {
|
||||
localStorage.setItem(
|
||||
"@paseo:onboarding",
|
||||
JSON.stringify({ completedAt: new Date(0).toISOString() }),
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
daemon: testDaemon,
|
||||
preferences: createAgentPreferences,
|
||||
seedNonce,
|
||||
extraHostsKey: EXTRA_HOSTS_KEY,
|
||||
onboardingCompleted: e2eOnboardingCompleted,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ export async function chooseCustomSkills(page: Page, selected: string[]): Promis
|
||||
await all.click();
|
||||
const keep = new Set(selected);
|
||||
for (const name of await listSkillChoices(page)) {
|
||||
if (!keep.has(name)) await skillSwitch(page, name).click();
|
||||
if (!keep.has(name)) await page.getByRole("checkbox", { name, exact: true }).click();
|
||||
}
|
||||
await expectSkillChoices(page, selected);
|
||||
}
|
||||
@@ -176,7 +176,7 @@ export async function cancelSkillSelection(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function toggleSkill(page: Page, name: string): Promise<void> {
|
||||
await skillSwitch(page, name).click();
|
||||
await page.getByRole("checkbox", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
export function answerNextRemovalWarning(
|
||||
@@ -222,21 +222,15 @@ export async function expectSkillFile(
|
||||
export async function expectSkillChoices(page: Page, selected: string[]): Promise<void> {
|
||||
const expected = new Set(selected);
|
||||
for (const name of await listSkillChoices(page)) {
|
||||
await expect(skillSwitch(page, name)).toBeChecked({
|
||||
await expect(page.getByRole("checkbox", { name, exact: true })).toBeChecked({
|
||||
checked: expected.has(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** One bundled skill's switch in the picker; "All skills" sits outside the list. */
|
||||
function skillSwitch(page: Page, name: string) {
|
||||
return page.getByTestId("skill-selection-list-card").getByRole("switch", { name, exact: true });
|
||||
}
|
||||
|
||||
async function listSkillChoices(page: Page): Promise<string[]> {
|
||||
return page
|
||||
.getByTestId("skill-selection-list-card")
|
||||
.getByRole("switch")
|
||||
.getByRole("checkbox")
|
||||
.evaluateAll((elements) =>
|
||||
elements
|
||||
.map((element) => element.getAttribute("aria-label") ?? "")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
export interface DirectoryBootstrapCounts {
|
||||
agents: number;
|
||||
@@ -294,8 +294,7 @@ function recordClientRequest(
|
||||
directoryStarts.total[directory] += 1;
|
||||
}
|
||||
|
||||
/** Gates the shared E2E daemon, or the daemon on `port` for a spec that runs its own. */
|
||||
export async function installDaemonWebSocketGate(page: Page, options: { port?: number } = {}) {
|
||||
export async function installDaemonWebSocketGate(page: Page) {
|
||||
let acceptingConnections = true;
|
||||
let reconnectWithFreshClient = false;
|
||||
let suppressAgentStream = false;
|
||||
@@ -446,11 +445,7 @@ export async function installDaemonWebSocketGate(page: Page, options: { port?: n
|
||||
return true;
|
||||
};
|
||||
|
||||
const routePattern =
|
||||
options.port === undefined
|
||||
? daemonWsRoutePattern()
|
||||
: wsRoutePatternForPort(String(options.port));
|
||||
await page.routeWebSocket(routePattern, (ws) => {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
blockedConnectionCount += 1;
|
||||
for (const resolve of blockedConnectionWaiters) resolve();
|
||||
|
||||
@@ -25,8 +25,6 @@ export interface IsolatedHostDaemon {
|
||||
}
|
||||
|
||||
export interface IsolatedHostDaemonOptions {
|
||||
/** Written to the daemon's config.json; `mutableRelay` adds `daemon.relay` to it. */
|
||||
config?: { daemon?: Record<string, unknown>; [key: string]: unknown };
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
mutableRelay?: {
|
||||
enabled: boolean;
|
||||
@@ -95,28 +93,6 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
function withMutableRelay(
|
||||
config: NonNullable<IsolatedHostDaemonOptions["config"]>,
|
||||
relay: NonNullable<IsolatedHostDaemonOptions["mutableRelay"]>,
|
||||
): NonNullable<IsolatedHostDaemonOptions["config"]> {
|
||||
const endpoint =
|
||||
relay.endpoint ??
|
||||
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
|
||||
return {
|
||||
...config,
|
||||
daemon: {
|
||||
...config.daemon,
|
||||
relay: {
|
||||
enabled: relay.enabled,
|
||||
endpoint,
|
||||
publicEndpoint: endpoint,
|
||||
useTls: false,
|
||||
publicUseTls: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function startIsolatedHostDaemon(
|
||||
serverId: string,
|
||||
options: IsolatedHostDaemonOptions = {},
|
||||
@@ -162,13 +138,24 @@ export async function startIsolatedHostDaemon(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const config = options.mutableRelay
|
||||
? withMutableRelay(options.config ?? {}, options.mutableRelay)
|
||||
: options.config;
|
||||
if (config) {
|
||||
if (options.mutableRelay) {
|
||||
const endpoint =
|
||||
options.mutableRelay.endpoint ??
|
||||
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({ version: 1, ...config })}\n`,
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
daemon: {
|
||||
relay: {
|
||||
enabled: options.mutableRelay.enabled,
|
||||
endpoint,
|
||||
publicEndpoint: endpoint,
|
||||
useTls: false,
|
||||
publicUseTls: false,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
const serverDir = publishedPackageRoot
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { addProjectFlow, addProjectFlowInput, chooseAddProjectMethod } from "./add-project-flow";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { startIsolatedHostDaemon, type IsolatedHostDaemonOptions } from "./isolated-host-daemon";
|
||||
|
||||
/** Points a fresh browser profile at one host, with no first-run record and no app settings. */
|
||||
export async function openAppAsNewUser(
|
||||
page: Page,
|
||||
host: { serverId: string; port: number },
|
||||
): Promise<void> {
|
||||
const seededHost = buildSeededHost({
|
||||
serverId: host.serverId,
|
||||
endpoint: `127.0.0.1:${host.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.route(/:6767\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:6767\b/, async (webSocket) => {
|
||||
await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." });
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ registryHost, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([registryHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ registryHost: seededHost, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
await page.goto("/");
|
||||
}
|
||||
|
||||
interface FirstLaunchRecord {
|
||||
appBeforeSetup: boolean;
|
||||
setupOpacityOnArrival: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the page from its first paint: whether the app's sidebar or home showed before setup,
|
||||
* and how opaque setup was when it arrived. Call before `openAppAsNewUser`.
|
||||
*/
|
||||
export async function recordFirstLaunch(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const record: FirstLaunchRecord = { appBeforeSetup: false, setupOpacityOnArrival: null };
|
||||
Object.assign(window, { __firstLaunch: record });
|
||||
const observer = new MutationObserver(() => {
|
||||
const setup = document.querySelector('[data-testid="onboarding-modal"] [role="dialog"]');
|
||||
if (setup) {
|
||||
record.setupOpacityOnArrival = Number(getComputedStyle(setup).opacity);
|
||||
observer.disconnect();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
document.querySelector(
|
||||
'[data-testid="open-project-submit"], [data-testid="sidebar-settings"]',
|
||||
)
|
||||
) {
|
||||
record.appBeforeSetup = true;
|
||||
}
|
||||
});
|
||||
observer.observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function readFirstLaunch(page: Page): Promise<FirstLaunchRecord> {
|
||||
return page.evaluate(
|
||||
() => (window as unknown as { __firstLaunch: FirstLaunchRecord }).__firstLaunch,
|
||||
);
|
||||
}
|
||||
|
||||
/** The loading screen the app shows while it starts. */
|
||||
export function loadingScreen(page: Page): Locator {
|
||||
return page.getByTestId("startup-splash");
|
||||
}
|
||||
|
||||
/** The empty home a host with no projects lands on. */
|
||||
export function emptyHome(page: Page): Locator {
|
||||
return page.getByTestId("open-project-submit");
|
||||
}
|
||||
|
||||
export function setupModal(page: Page): Locator {
|
||||
return page.getByTestId("onboarding-modal").getByRole("dialog");
|
||||
}
|
||||
|
||||
export async function expectSetupStep(page: Page, title: string): Promise<void> {
|
||||
await expect(setupModal(page).getByRole("heading", { name: title, exact: true })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pickTheme(page: Page, name: string): Promise<void> {
|
||||
const tile = setupModal(page).getByRole("radio", { name, exact: true });
|
||||
await tile.click();
|
||||
await expect(tile).toBeChecked();
|
||||
}
|
||||
|
||||
export async function continueSetup(page: Page): Promise<void> {
|
||||
await setupModal(page).getByRole("button", { name: "Continue", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function skipSetup(page: Page): Promise<void> {
|
||||
await setupModal(page).getByRole("button", { name: "Skip setup", exact: true }).click();
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Adds a folder through the regular Add project flow, opened from the setup's Projects step. */
|
||||
export async function addProjectFromSetup(page: Page, projectPath: string): Promise<void> {
|
||||
await setupModal(page).getByRole("button", { name: "Add project", exact: true }).click();
|
||||
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
|
||||
await chooseAddProjectMethod(page, "directory-search");
|
||||
await addProjectFlowInput(page).fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(addProjectFlow(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function showSetupAgainFromSettings(page: Page): Promise<void> {
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await page.getByRole("button", { name: "Show setup again", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function goToSetupStep(page: Page, label: string): Promise<void> {
|
||||
await setupModal(page).getByRole("tab", { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
export function paseoToolsSwitch(scope: Page | Locator): Locator {
|
||||
return scope.getByRole("switch", { name: "Inject Paseo tools", exact: true });
|
||||
}
|
||||
|
||||
/** Whether the host gives its agents Paseo tools, read from the host itself. */
|
||||
export async function hostGivesAgentsPaseoTools(host: { port: number }): Promise<boolean> {
|
||||
const client = await connectDaemonClient<
|
||||
Pick<InternalDaemonClient, "connect" | "close" | "getDaemonConfig">
|
||||
>({ clientIdPrefix: "app-e2e-onboarding", port: host.port });
|
||||
try {
|
||||
return (await client.getDaemonConfig()).config.mcp.injectIntoAgents !== false;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function finishSetup(page: Page): Promise<void> {
|
||||
await setupModal(page).getByRole("button", { name: "Finish", exact: true }).click();
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Opens the Phone step's Relay row: the pair device modal, over setup. */
|
||||
export async function openRelayPairingFromSetup(page: Page): Promise<Locator> {
|
||||
await setupModal(page).getByRole("button", { name: "Relay", exact: true }).click();
|
||||
const pairing = page.getByTestId("onboarding-pair-device-card");
|
||||
await expect(pairing).toBeVisible();
|
||||
return pairing;
|
||||
}
|
||||
|
||||
export async function closeRelayPairing(pairing: Locator): Promise<void> {
|
||||
await pairing.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(pairing).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Clicks the Phone step's Tailscale row and returns the docs page it opened. */
|
||||
export async function openTailscaleDocsFromSetup(page: Page): Promise<string> {
|
||||
await page
|
||||
.context()
|
||||
.route("https://paseo.sh/**", (route) =>
|
||||
route.fulfill({ contentType: "text/html", body: "<title>Paseo docs</title>" }),
|
||||
);
|
||||
const popup = page.waitForEvent("popup");
|
||||
await setupModal(page).getByRole("link", { name: "Tailscale, Docs", exact: true }).click();
|
||||
const docs = await popup;
|
||||
await docs.waitForLoadState("domcontentloaded");
|
||||
const url = docs.url();
|
||||
await docs.close();
|
||||
return url;
|
||||
}
|
||||
|
||||
export interface SetupHost {
|
||||
serverId: string;
|
||||
port: number;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An isolated host whose only agent history is the Claude sessions given here, so suggestions
|
||||
* never depend on the machine running the test. Its HOME is a scratch folder, so the skills setup
|
||||
* installs never land in the real ~/.agents, ~/.claude or ~/.codex. `withBrokenProvider` adds a
|
||||
* provider that cannot start.
|
||||
*/
|
||||
export async function startSetupHost(
|
||||
label: string,
|
||||
options: {
|
||||
claudeSessionsIn?: readonly string[];
|
||||
withBrokenProvider?: boolean;
|
||||
mutableRelay?: IsolatedHostDaemonOptions["mutableRelay"];
|
||||
} = {},
|
||||
): Promise<SetupHost> {
|
||||
const serverId = `srv_onboarding_${label}_${randomUUID().replaceAll("-", "").slice(0, 8)}`;
|
||||
const claudeConfigDir = await seedClaudeHistory(options.claudeSessionsIn ?? []);
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-onboarding-home-"));
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
config: claudeOnlyConfig({ withBrokenProvider: options.withBrokenProvider ?? false }),
|
||||
environment: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: claudeConfigDir },
|
||||
mutableRelay: options.mutableRelay,
|
||||
});
|
||||
return {
|
||||
serverId,
|
||||
port: daemon.port,
|
||||
close: async () => {
|
||||
await daemon.close();
|
||||
await rm(claudeConfigDir, { recursive: true, force: true });
|
||||
await rm(home, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes one recent Claude session per folder into a fresh CLAUDE_CONFIG_DIR. */
|
||||
async function seedClaudeHistory(cwds: readonly string[]): Promise<string> {
|
||||
const configDir = await mkdtemp(path.join(tmpdir(), "paseo-onboarding-claude-"));
|
||||
const newest = Date.now() - 60_000;
|
||||
for (const [index, cwd] of cwds.entries()) {
|
||||
const sessionId = `onboarding-session-${index + 1}`;
|
||||
const projectDir = path.join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"));
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
const sessionPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
const record = {
|
||||
type: "user",
|
||||
uuid: `${sessionId}-user`,
|
||||
message: { role: "user", content: `Work on session ${index + 1}` },
|
||||
cwd,
|
||||
sessionId,
|
||||
};
|
||||
await writeFile(sessionPath, `${JSON.stringify(record)}\n`);
|
||||
const timestamp = new Date(newest - index * 60_000);
|
||||
await utimes(sessionPath, timestamp, timestamp);
|
||||
}
|
||||
return configDir;
|
||||
}
|
||||
|
||||
function claudeOnlyConfig(options: { withBrokenProvider: boolean }) {
|
||||
const brokenProvider = {
|
||||
"broken-acp": {
|
||||
extends: "acp",
|
||||
label: "Broken ACP",
|
||||
command: ["missing-agent-command", "acp"],
|
||||
},
|
||||
};
|
||||
return {
|
||||
agents: {
|
||||
providers: {
|
||||
codex: { enabled: false },
|
||||
copilot: { enabled: false },
|
||||
omp: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
...(options.withBrokenProvider ? brokenProvider : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The switch that picks a suggested project for adding. */
|
||||
export function projectSuggestion(page: Page, name: string): Locator {
|
||||
return setupModal(page).getByRole("switch", { name, exact: true });
|
||||
}
|
||||
|
||||
/** The whole suggestion row, with its path, last use and any add error beside the switch. */
|
||||
export function projectSuggestionRow(page: Page, name: string): Locator {
|
||||
return setupModal(page)
|
||||
.getByTestId("project-suggestion")
|
||||
.filter({ has: page.getByRole("switch", { name, exact: true }) });
|
||||
}
|
||||
|
||||
/** The footer's main button, which adds the checked suggestions when there are any. */
|
||||
export function setupPrimaryButton(page: Page, label: string): Locator {
|
||||
return setupModal(page).getByRole("button", { name: label, exact: true });
|
||||
}
|
||||
@@ -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
@@ -1,5 +1,6 @@
|
||||
package sh.paseo.terminal
|
||||
|
||||
import android.os.Build
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
@@ -43,6 +44,13 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.2",
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import {
|
||||
createControlGeometry,
|
||||
resolveControlInteractionStyles,
|
||||
type ControlSize,
|
||||
type FieldControlSize,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { Field } from "@/components/ui/form-field";
|
||||
import { identityForeground } from "@/styles/identity-colors";
|
||||
@@ -153,7 +153,7 @@ export function AgentProfileAppearanceField({
|
||||
icon: string;
|
||||
color: string;
|
||||
onChange: (next: { icon: string; color: string }) => void;
|
||||
size?: ControlSize;
|
||||
size?: FieldControlSize;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
triggerTestID?: string;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { AgentFeature } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type ControlSize } from "@/components/ui/control-geometry";
|
||||
import { type FieldControlSize } from "@/components/ui/control-geometry";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import {
|
||||
SelectField,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type SelectFieldOption,
|
||||
} from "@/components/ui/select-field";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { AgentProfileAppearanceField } from "./agent-profile-appearance-field";
|
||||
import type {
|
||||
@@ -132,7 +133,7 @@ function OpenAgentProfileEditModal({
|
||||
onSave,
|
||||
}: AgentProfileEditModalProps & { onDismiss: () => void }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const controlSize: ControlSize = "md";
|
||||
const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
|
||||
const snapshot = useMemo(
|
||||
() => ({ mode, ...(profile ? { profile } : {}), ...(seed ? { seed } : {}) }),
|
||||
[mode, profile, seed],
|
||||
@@ -409,7 +410,7 @@ function AgentProfileFeatureRow({
|
||||
feature: AgentFeature;
|
||||
isFirst: boolean;
|
||||
disabled: boolean;
|
||||
size: ControlSize;
|
||||
size: FieldControlSize;
|
||||
model: AgentProfileFormModel;
|
||||
}): ReactElement {
|
||||
const rowStyle = useMemo(
|
||||
|
||||
@@ -103,6 +103,7 @@ export function AgentProfileRow({
|
||||
<View style={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={moveUpIcon}
|
||||
onPress={handleMoveUp}
|
||||
disabled={isFirst}
|
||||
@@ -111,6 +112,7 @@ export function AgentProfileRow({
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={moveDownIcon}
|
||||
onPress={handleMoveDown}
|
||||
disabled={isLast}
|
||||
@@ -119,6 +121,7 @@ export function AgentProfileRow({
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={editIcon}
|
||||
onPress={handleEdit}
|
||||
accessibilityLabel={t("settings.host.agentProfiles.editProfile")}
|
||||
@@ -126,6 +129,7 @@ export function AgentProfileRow({
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={removeIcon}
|
||||
onPress={handleRemove}
|
||||
accessibilityLabel={t("settings.host.agentProfiles.remove")}
|
||||
|
||||
@@ -124,6 +124,7 @@ export function AgentProfilesSection({ serverId }: { serverId: string }): ReactE
|
||||
() => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={addIcon}
|
||||
onPress={handleAddOpen}
|
||||
disabled={!profiles}
|
||||
@@ -180,7 +181,7 @@ export function AgentProfilesSection({ serverId }: { serverId: string }): ReactE
|
||||
<Text style={styles.emptyText} testID="agent-profiles-empty">
|
||||
{t("settings.host.agentProfiles.emptyState")}
|
||||
</Text>
|
||||
<Button leftIcon={addIcon} onPress={handleAddOpen} disabled={!profiles}>
|
||||
<Button size="sm" leftIcon={addIcon} onPress={handleAddOpen} disabled={!profiles}>
|
||||
{t("settings.host.agentProfiles.newProfile")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -3,27 +3,29 @@ import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { useFocusEffect } from "expo-router/react-navigation";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native";
|
||||
import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages";
|
||||
import type { TFunction } from "i18next";
|
||||
import { SettingsCard, SettingsRow, SettingsSection } from "@/components/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { SkillSelectionSheet } from "./selection-sheet";
|
||||
import { useAgentSkills, type AgentSkillsController } from "./use-agent-skills";
|
||||
|
||||
export { useAgentSkills, type AgentSkillsController } from "./use-agent-skills";
|
||||
import { useAgentSkills } from "./use-agent-skills";
|
||||
|
||||
const OP_KIND_ORDER: Record<AgentSkillOperation["kind"], number> = {
|
||||
add: 0,
|
||||
update: 1,
|
||||
delete: 2,
|
||||
};
|
||||
const ThemedBlocks = withUnistyles(Blocks);
|
||||
const ThemedCheck = withUnistyles(Check);
|
||||
const ThemedSettings = withUnistyles(Settings2);
|
||||
const ThemedArrowUpRight = withUnistyles(ArrowUpRight);
|
||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction): string {
|
||||
@@ -33,50 +35,91 @@ function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction):
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Whether the host has Paseo's skills installed in any form, current or not. */
|
||||
export function areSkillsInstalled(status: AgentSkillsStatus | null): boolean {
|
||||
return status !== null && status.state !== "not-installed";
|
||||
}
|
||||
|
||||
/** Whether the saved selection names any skill there is to install. */
|
||||
export function hasSelectedSkills(status: AgentSkillsStatus | null): boolean {
|
||||
if (!status) return false;
|
||||
if (status.selection.mode === "all") return status.available.length > 0;
|
||||
return status.selection.skills.some((name) => status.available.includes(name));
|
||||
}
|
||||
|
||||
export function AgentSkillsSection({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const skills = useAgentSkills(serverId);
|
||||
const { refresh, connected, supported, reconcile, uninstall } = skills;
|
||||
const refreshSkills = skills.refresh;
|
||||
const skillsConnected = skills.connected;
|
||||
const skillsSupported = skills.supported;
|
||||
const [isChoosing, setIsChoosing] = useState(false);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (connected && supported) void refresh();
|
||||
if (skillsConnected && skillsSupported) void refreshSkills();
|
||||
return undefined;
|
||||
}, [refresh, connected, supported]),
|
||||
}, [refreshSkills, skillsConnected, skillsSupported]),
|
||||
);
|
||||
const handleValueChange = useCallback(
|
||||
(next: boolean) => {
|
||||
void (next ? reconcile() : uninstall());
|
||||
},
|
||||
[reconcile, uninstall],
|
||||
const maintenanceOps = useMemo(
|
||||
() => skills.status?.ops.filter((op) => op.kind !== "delete") ?? [],
|
||||
[skills.status?.ops],
|
||||
);
|
||||
const docsLink = useMemo(
|
||||
() => ({
|
||||
href: SKILLS_DOCS_URL,
|
||||
label: t("settings.host.skills.docs"),
|
||||
accessibilityLabel: t("settings.host.skills.openDocs"),
|
||||
}),
|
||||
[t],
|
||||
const state =
|
||||
skills.status?.state === "drift" && maintenanceOps.length === 0
|
||||
? "up-to-date"
|
||||
: (skills.status?.state ?? null);
|
||||
const hasSelected =
|
||||
(skills.status?.selection.mode === "all" && skills.status.available.length > 0) ||
|
||||
(skills.status?.selection.mode === "custom" &&
|
||||
skills.status.selection.skills.some((name) => skills.status?.available.includes(name)));
|
||||
|
||||
const handleReconcile = useCallback(async () => {
|
||||
if (skills.isWorking) return;
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.host.skills.updateTitle"),
|
||||
message:
|
||||
maintenanceOps.length > 0
|
||||
? formatUpdateMessage(maintenanceOps, t)
|
||||
: t("settings.host.skills.updateFallback"),
|
||||
confirmLabel: t("settings.host.skills.actions.update"),
|
||||
});
|
||||
if (confirmed) await skills.reconcile();
|
||||
}, [maintenanceOps, skills, t]);
|
||||
const handleUninstall = useCallback(async () => {
|
||||
if (skills.isWorking) return;
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.host.skills.uninstallTitle"),
|
||||
message: t("settings.host.skills.uninstallMessage"),
|
||||
confirmLabel: t("settings.host.skills.actions.uninstall"),
|
||||
destructive: true,
|
||||
});
|
||||
if (confirmed) await skills.uninstall();
|
||||
}, [skills, t]);
|
||||
const chooseIcon = useMemo(
|
||||
() => <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedMapping} />,
|
||||
[],
|
||||
);
|
||||
const handleOpen = useCallback(() => setIsChoosing(true), []);
|
||||
const handleClose = useCallback(() => setIsChoosing(false), []);
|
||||
const handleOpenDocs = useCallback(() => {
|
||||
void openExternalUrl(SKILLS_DOCS_URL);
|
||||
}, []);
|
||||
const docsIcon = useMemo(
|
||||
() => <ThemedArrowUpRight size={ICON_SIZE.sm} uniProps={mutedMapping} />,
|
||||
[],
|
||||
);
|
||||
const trailing = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={docsIcon}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={handleOpenDocs}
|
||||
accessibilityLabel={t("settings.host.skills.openDocs")}
|
||||
>
|
||||
{t("settings.host.skills.docs")}
|
||||
</Button>
|
||||
),
|
||||
[docsIcon, handleOpenDocs, t],
|
||||
);
|
||||
|
||||
if (!connected || !supported) {
|
||||
if (!skills.connected || !skills.supported) {
|
||||
return (
|
||||
<SettingsSection title={t("settings.host.skills.sectionTitle")} link={docsLink}>
|
||||
<SettingsSection title={t("settings.host.skills.sectionTitle")} trailing={trailing}>
|
||||
<View style={settingsStyles.card} testID="host-agent-skills-unavailable">
|
||||
<View style={styles.emptyCard}>
|
||||
<Text style={styles.mutedText}>
|
||||
{connected
|
||||
{skills.connected
|
||||
? t("settings.host.skills.unsupported")
|
||||
: t("settings.host.skills.unavailable")}
|
||||
</Text>
|
||||
@@ -87,112 +130,109 @@ export function AgentSkillsSection({ serverId }: { serverId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("settings.host.skills.sectionTitle")} link={docsLink}>
|
||||
<SettingsCard testID="host-agent-skills-card">
|
||||
<AgentSkillsRow
|
||||
skills={skills}
|
||||
value={areSkillsInstalled(skills.status)}
|
||||
onValueChange={handleValueChange}
|
||||
<SettingsSection title={t("settings.host.skills.sectionTitle")} trailing={trailing}>
|
||||
<View style={settingsStyles.card} testID="host-agent-skills-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<ThemedBlocks size={ICON_SIZE.md} uniProps={foregroundMapping} />
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.host.skills.title")}</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{state === "drift"
|
||||
? t("settings.host.skills.updateAvailable")
|
||||
: t("settings.host.skills.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actionsRow}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={chooseIcon}
|
||||
onPress={handleOpen}
|
||||
disabled={skills.status === null || skills.isWorking}
|
||||
accessibilityLabel={t("settings.host.skills.choose")}
|
||||
/>
|
||||
{hasSelected ? (
|
||||
<SkillsActions
|
||||
state={state}
|
||||
isWorking={skills.isWorking}
|
||||
onInstall={skills.reconcile}
|
||||
onReconcile={handleReconcile}
|
||||
onUninstall={handleUninstall}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{skills.status ? (
|
||||
<SkillSelectionSheet
|
||||
visible={isChoosing}
|
||||
available={skills.status.available}
|
||||
selection={skills.status.selection}
|
||||
isSaving={skills.isWorking}
|
||||
onSave={skills.saveSelection}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The orchestration skills row: a switch for whether they are installed, a gear that opens the
|
||||
* skill picker, and Update when the installed skills fall behind the bundled ones. The caller
|
||||
* decides what the switch is bound to; the picker and Update always act on the host.
|
||||
*/
|
||||
export function AgentSkillsRow({
|
||||
skills,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled = false,
|
||||
function SkillsActions({
|
||||
state,
|
||||
isWorking,
|
||||
onReconcile,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
}: {
|
||||
skills: AgentSkillsController;
|
||||
value: boolean;
|
||||
onValueChange: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
state: AgentSkillsStatus["state"] | null;
|
||||
isWorking: boolean;
|
||||
onReconcile: () => void;
|
||||
onInstall: () => void;
|
||||
onUninstall: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { status, isWorking, reconcile, saveSelection } = skills;
|
||||
const [isChoosing, setIsChoosing] = useState(false);
|
||||
// Deleting a deselected skill is a choice made in the picker, not an update to offer here.
|
||||
const maintenanceOps = useMemo(
|
||||
() => status?.ops.filter((op) => op.kind !== "delete") ?? [],
|
||||
[status?.ops],
|
||||
);
|
||||
const needsUpdate = status?.state === "drift" && maintenanceOps.length > 0;
|
||||
|
||||
const handleUpdate = useCallback(async () => {
|
||||
if (isWorking) return;
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.host.skills.updateTitle"),
|
||||
message: formatUpdateMessage(maintenanceOps, t),
|
||||
confirmLabel: t("settings.host.skills.actions.update"),
|
||||
});
|
||||
if (confirmed) await reconcile();
|
||||
}, [isWorking, maintenanceOps, reconcile, t]);
|
||||
const handleOpen = useCallback(() => setIsChoosing(true), []);
|
||||
const handleClose = useCallback(() => setIsChoosing(false), []);
|
||||
const chooseIcon = useMemo(
|
||||
() => <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedMapping} />,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsRow
|
||||
label={t("settings.host.skills.title")}
|
||||
hint={
|
||||
needsUpdate
|
||||
? t("settings.host.skills.updateAvailable")
|
||||
: t("settings.host.skills.description")
|
||||
}
|
||||
>
|
||||
<View style={styles.controls}>
|
||||
{needsUpdate ? (
|
||||
<Button variant="outline" onPress={handleUpdate} disabled={disabled || isWorking}>
|
||||
{isWorking
|
||||
? t("settings.host.skills.actions.working")
|
||||
: t("settings.host.skills.actions.update")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
leftIcon={chooseIcon}
|
||||
onPress={handleOpen}
|
||||
disabled={disabled || status === null || isWorking}
|
||||
accessibilityLabel={t("settings.host.skills.choose")}
|
||||
/>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
// Nothing picked means nothing to install; the gear is the way forward.
|
||||
disabled={
|
||||
disabled || status === null || isWorking || (!value && !hasSelectedSkills(status))
|
||||
}
|
||||
accessibilityLabel={t("settings.host.skills.title")}
|
||||
/>
|
||||
if (state === "up-to-date") {
|
||||
return (
|
||||
<View style={styles.actionsRow}>
|
||||
<View style={styles.installedLabel}>
|
||||
<ThemedCheck size={14} uniProps={mutedMapping} />
|
||||
<Text style={styles.mutedText}>{t("settings.host.skills.actions.installed")}</Text>
|
||||
</View>
|
||||
</SettingsRow>
|
||||
{status ? (
|
||||
<SkillSelectionSheet
|
||||
visible={isChoosing}
|
||||
available={status.available}
|
||||
selection={status.selection}
|
||||
isSaving={isWorking}
|
||||
onSave={saveSelection}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
{t("settings.host.skills.actions.uninstall")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (state === "drift") {
|
||||
return (
|
||||
<View style={styles.actionsRow}>
|
||||
<Button variant="outline" size="sm" onPress={onReconcile} disabled={isWorking}>
|
||||
{isWorking
|
||||
? t("settings.host.skills.actions.working")
|
||||
: t("settings.host.skills.actions.update")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
{t("settings.host.skills.actions.uninstall")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
|
||||
{isWorking
|
||||
? t("settings.host.skills.actions.installing")
|
||||
: t("settings.host.skills.actions.install")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
controls: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
rowTitleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
installedLabel: { flexDirection: "row", alignItems: "center", gap: 4 },
|
||||
mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
actionsRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
emptyCard: { padding: theme.spacing[4] },
|
||||
}));
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCard, SettingsRow, SettingsSection } from "@/components/settings";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import type {
|
||||
AgentSkillSelection as SkillSelection,
|
||||
AgentSkillsSaveResult as SkillsSaveResult,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
const ThemedCheck = withUnistyles(Check);
|
||||
const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
|
||||
|
||||
interface SkillSelectionSheetProps {
|
||||
visible: boolean;
|
||||
available: readonly string[];
|
||||
@@ -152,11 +157,12 @@ export function SkillSelectionSheet({
|
||||
footer={footer}
|
||||
testID="skill-selection-sheet"
|
||||
>
|
||||
<SettingsCard testID="skill-selection-all-card">
|
||||
<SettingsRow
|
||||
label={t("settings.host.skills.chooseAll")}
|
||||
hint={t("settings.host.skills.chooseAllHint")}
|
||||
>
|
||||
<View style={settingsStyles.card} testID="skill-selection-all-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.host.skills.chooseAll")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.host.skills.chooseAllHint")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={allSkills}
|
||||
onValueChange={handleToggleAll}
|
||||
@@ -164,29 +170,30 @@ export function SkillSelectionSheet({
|
||||
accessibilityLabel={t("settings.host.skills.chooseAll")}
|
||||
testID="skill-selection-all"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* The sheet body already spaces its blocks apart, so the section owns
|
||||
only the gap between its label and the list the label names. */}
|
||||
<SettingsSection title={t("settings.host.skills.chooseList")} flush>
|
||||
<SettingsCard testID="skill-selection-list-card">
|
||||
<View style={settingsStyles.card} testID="skill-selection-list-card">
|
||||
{available.length === 0 ? (
|
||||
<View style={settingsStyles.row}>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.host.skills.chooseEmpty")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
available.map((name) => (
|
||||
<SkillRow
|
||||
available.map((name, index) => (
|
||||
<SkillCheckboxRow
|
||||
key={name}
|
||||
name={name}
|
||||
checked={isSkillSelected(draft, name)}
|
||||
disabled={allSkills || isSaving}
|
||||
withBorder={index > 0}
|
||||
onToggle={handleToggleSkill}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SettingsCard>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
{saveError ? <Text style={settingsStyles.rowError}>{saveError}</Text> : null}
|
||||
@@ -194,27 +201,52 @@ export function SkillSelectionSheet({
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({
|
||||
name,
|
||||
checked,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
interface SkillCheckboxRowProps {
|
||||
name: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
withBorder: boolean;
|
||||
onToggle: (name: string) => void;
|
||||
}) {
|
||||
const handleValueChange = useCallback(() => onToggle(name), [name, onToggle]);
|
||||
}
|
||||
|
||||
function SkillCheckboxRow({
|
||||
name,
|
||||
checked,
|
||||
disabled,
|
||||
withBorder,
|
||||
onToggle,
|
||||
}: SkillCheckboxRowProps) {
|
||||
const handlePress = useCallback(() => onToggle(name), [name, onToggle]);
|
||||
const accessibilityState = useMemo(() => ({ checked, disabled }), [checked, disabled]);
|
||||
const rowStyle = useMemo(
|
||||
() => [
|
||||
settingsStyles.row,
|
||||
withBorder ? settingsStyles.rowBorder : null,
|
||||
styles.checkboxRow,
|
||||
disabled ? styles.disabledRow : null,
|
||||
],
|
||||
[disabled, withBorder],
|
||||
);
|
||||
const boxStyle = useMemo(
|
||||
() => [styles.checkbox, checked ? styles.checkboxChecked : null],
|
||||
[checked],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsRow label={name}>
|
||||
<Switch
|
||||
value={checked}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled}
|
||||
accessibilityLabel={name}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<Pressable
|
||||
style={rowStyle}
|
||||
onPress={handlePress}
|
||||
disabled={disabled}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={name}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-checked={checked}
|
||||
>
|
||||
<View style={boxStyle}>
|
||||
{checked ? <ThemedCheck size={14} uniProps={checkedIconMapping} /> : null}
|
||||
</View>
|
||||
<Text style={settingsStyles.rowTitle}>{name}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,4 +259,24 @@ const styles = StyleSheet.create((theme) => ({
|
||||
footerButton: {
|
||||
flex: 1,
|
||||
},
|
||||
checkboxRow: {
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
disabledRow: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
checkbox: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
checkboxChecked: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -10,9 +10,6 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
export type AgentSkillsController = ReturnType<typeof useAgentSkills>;
|
||||
|
||||
export function useAgentSkills(serverId: string) {
|
||||
const { t } = useTranslation();
|
||||
@@ -86,30 +83,12 @@ export function useAgentSkills(serverId: string) {
|
||||
const refresh = useCallback(async () => {
|
||||
await refetch();
|
||||
}, [refetch]);
|
||||
// Both resolve to whether the host ended in the asked-for state; failures are already toasted.
|
||||
const runReconcile = useCallback(
|
||||
() =>
|
||||
reconcileAsync().then(
|
||||
(status) => status.state !== "not-installed",
|
||||
() => false,
|
||||
),
|
||||
[reconcileAsync],
|
||||
);
|
||||
// Uninstalling deletes whole skill folders, including anything the user added inside them, so
|
||||
// it always asks first. Declining resolves false, as a failure does.
|
||||
const runReconcile = useCallback(async () => {
|
||||
await reconcileAsync().catch(() => undefined);
|
||||
}, [reconcileAsync]);
|
||||
const runUninstall = useCallback(async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.host.skills.uninstallTitle"),
|
||||
message: t("settings.host.skills.uninstallMessage"),
|
||||
confirmLabel: t("settings.host.skills.actions.uninstall"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return false;
|
||||
return uninstallAsync().then(
|
||||
(status) => status.state === "not-installed",
|
||||
() => false,
|
||||
);
|
||||
}, [t, uninstallAsync]);
|
||||
await uninstallAsync().catch(() => undefined);
|
||||
}, [uninstallAsync]);
|
||||
const saveSelection = useCallback(
|
||||
(selection: AgentSkillSelection, confirmedRemovals: readonly string[] = []) =>
|
||||
saveAsync({ selection, confirmedRemovals }),
|
||||
|
||||
@@ -147,7 +147,7 @@ export function ChatFind({
|
||||
{state.failure && (
|
||||
<View style={styles.error}>
|
||||
<Text style={styles.errorText}>{t(FAILURE_TEXT[state.failure])}</Text>
|
||||
<Button size="sm" variant="ghost" onPress={model.retry}>
|
||||
<Button size="xs" variant="ghost" onPress={model.retry}>
|
||||
{t("paneFind.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -468,7 +468,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
workspaceRoot,
|
||||
resolveRelativeFiles: true,
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
|
||||
@@ -35,7 +35,6 @@ import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { ChangelogHost } from "@/changelog";
|
||||
import { OnboardingHost, useIsSetupDecisionPending } from "@/onboarding";
|
||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
@@ -138,7 +137,6 @@ export interface HostRuntimeBootstrapState {
|
||||
splashError: string | null;
|
||||
retry: () => void;
|
||||
hasGivenUpWaitingForHost: boolean;
|
||||
isWaitingForSetup: boolean;
|
||||
storeReady: boolean;
|
||||
startupBlocker: StartupBlocker;
|
||||
}
|
||||
@@ -147,7 +145,6 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||
splashError: null,
|
||||
retry: () => {},
|
||||
hasGivenUpWaitingForHost: false,
|
||||
isWaitingForSetup: false,
|
||||
storeReady: false,
|
||||
startupBlocker: { kind: "none" },
|
||||
});
|
||||
@@ -244,9 +241,6 @@ function useDaemonStartIsRunning(): boolean {
|
||||
}
|
||||
|
||||
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
|
||||
// How long the splash waits on top of host startup to learn whether setup opens. A slow host
|
||||
// lands on the home instead, and setup opens over it once it is decided.
|
||||
const STARTUP_SETUP_WAIT_TIMEOUT_MS = 3_000;
|
||||
|
||||
async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
if (!shouldUseDesktopDaemon()) {
|
||||
@@ -308,23 +302,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, [shouldRunGiveUpTimer]);
|
||||
|
||||
const isSetupDecisionPending = useIsSetupDecisionPending();
|
||||
const [hasGivenUpWaitingForSetup, setHasGivenUpWaitingForSetup] = useState(false);
|
||||
const isWaitingForSetup = isSetupDecisionPending && !hasGivenUpWaitingForSetup;
|
||||
const shouldRunSetupWaitTimer = isWaitingForSetup && startupBlocker.kind === "none";
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRunSetupWaitTimer) {
|
||||
return;
|
||||
}
|
||||
const handle = setTimeout(() => {
|
||||
setHasGivenUpWaitingForSetup(true);
|
||||
}, STARTUP_SETUP_WAIT_TIMEOUT_MS);
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [shouldRunSetupWaitTimer]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
|
||||
@@ -335,15 +312,8 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const storeReady = resolveStartupNavigationReady({ startupBlocker });
|
||||
|
||||
const state = useMemo<HostRuntimeBootstrapState>(
|
||||
() => ({
|
||||
splashError,
|
||||
retry,
|
||||
hasGivenUpWaitingForHost,
|
||||
isWaitingForSetup,
|
||||
storeReady,
|
||||
startupBlocker,
|
||||
}),
|
||||
[splashError, retry, hasGivenUpWaitingForHost, isWaitingForSetup, storeReady, startupBlocker],
|
||||
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }),
|
||||
[splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -534,10 +504,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<ChangelogHost />
|
||||
<QuittingOverlay />
|
||||
</AppearanceStyleBoundary>
|
||||
{/* Setup changes the theme while it is open, so it stays out of the remount above. Its
|
||||
colors are CSS variables and themed props, which update in place, and setup changes no
|
||||
font size. */}
|
||||
<OnboardingHost />
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -45,19 +45,11 @@ export default function Index() {
|
||||
}),
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
isWaitingForSetup: bootstrapState.isWaitingForSetup,
|
||||
});
|
||||
|
||||
const splash = <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
if (startupRoute.kind === "redirect") {
|
||||
// The loading screen stays up until the target route paints; a redirect alone renders nothing.
|
||||
return (
|
||||
<>
|
||||
{splash}
|
||||
<Redirect href={startupRoute.href} />
|
||||
</>
|
||||
);
|
||||
return <Redirect href={startupRoute.href} />;
|
||||
}
|
||||
|
||||
return splash;
|
||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
}
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import type { PluginThemeOption } from "@/plugins/themes";
|
||||
import {
|
||||
THEME_OPTIONS,
|
||||
PLUGIN_THEME_PREFERENCE,
|
||||
darkTheme,
|
||||
lightTheme,
|
||||
type Theme,
|
||||
} from "@/styles/theme";
|
||||
import { useContributedThemes } from "./provider";
|
||||
|
||||
type BuiltInThemePreference = Exclude<AppSettings["theme"], typeof PLUGIN_THEME_PREFERENCE>;
|
||||
|
||||
// System leads the grid because it is the default.
|
||||
const PICKER_THEME_OPTIONS = [
|
||||
...THEME_OPTIONS.filter((option) => option.name === "auto"),
|
||||
...THEME_OPTIONS.filter((option) => option.name !== "auto"),
|
||||
];
|
||||
|
||||
const PREVIEW_ASPECT_RATIO = 16 / 9;
|
||||
|
||||
/**
|
||||
* Every theme as a tile with a small preview drawn from its own tokens. Picking a tile applies
|
||||
* the theme to the whole app at once. Settings → Appearance and first-run setup share it.
|
||||
*/
|
||||
export function ThemePicker() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const { options: pluginThemes, selected: selectedPluginTheme, select } = useContributedThemes();
|
||||
// A selected contribution that is no longer installed shows the fallback the app renders.
|
||||
const builtInValue =
|
||||
settings.theme === PLUGIN_THEME_PREFERENCE ? DEFAULT_THEME_PREFERENCE : settings.theme;
|
||||
|
||||
const selectBuiltIn = useCallback(
|
||||
(theme: BuiltInThemePreference) => {
|
||||
void updateSettings({ theme });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.grid}
|
||||
accessibilityRole="radiogroup"
|
||||
accessibilityLabel={t("settings.appearance.theme.title")}
|
||||
>
|
||||
{PICKER_THEME_OPTIONS.map((option) => (
|
||||
<BuiltInThemeTile
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
selected={selectedPluginTheme === null && builtInValue === option.name}
|
||||
onSelect={selectBuiltIn}
|
||||
/>
|
||||
))}
|
||||
{pluginThemes.map((option) => (
|
||||
<PluginThemeTile
|
||||
key={option.id}
|
||||
option={option}
|
||||
selected={selectedPluginTheme?.id === option.id}
|
||||
onSelect={select}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BuiltInThemeTile({
|
||||
value,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
value: BuiltInThemePreference;
|
||||
selected: boolean;
|
||||
onSelect: (value: BuiltInThemePreference) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handlePress = useCallback(() => onSelect(value), [onSelect, value]);
|
||||
const option = THEME_OPTIONS.find((candidate) => candidate.name === value);
|
||||
return (
|
||||
<ThemeTile
|
||||
label={t(`settings.appearance.theme.options.${value}`)}
|
||||
selected={selected}
|
||||
onPress={handlePress}
|
||||
previewTheme={option && "theme" in option ? option.theme : "system"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginThemeTile({
|
||||
option,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
option: PluginThemeOption;
|
||||
selected: boolean;
|
||||
onSelect: (option: PluginThemeOption) => void;
|
||||
}) {
|
||||
const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
|
||||
return (
|
||||
<ThemeTile
|
||||
label={option.name}
|
||||
selected={selected}
|
||||
onPress={handlePress}
|
||||
previewTheme={option.theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeTile({
|
||||
label,
|
||||
selected,
|
||||
onPress,
|
||||
previewTheme,
|
||||
}: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
/** "system" follows the OS, so its preview shows both halves. */
|
||||
previewTheme: Theme | "system";
|
||||
}) {
|
||||
const accessibilityState = useMemo(() => ({ checked: selected }), [selected]);
|
||||
const tileStyle = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.tile,
|
||||
hovered && !selected && styles.tileHovered,
|
||||
selected && styles.tileSelected,
|
||||
],
|
||||
[selected],
|
||||
);
|
||||
return (
|
||||
<View style={styles.cell}>
|
||||
<PressHighlight
|
||||
style={tileStyle}
|
||||
onPress={onPress}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={label}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-checked={selected}
|
||||
>
|
||||
<View style={styles.previewFrame}>
|
||||
{previewTheme === "system" ? (
|
||||
<SystemThemePreview />
|
||||
) : (
|
||||
<ThemePreview theme={previewTheme} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</PressHighlight>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** A sketch of the app — sidebar, a few lines of text and an accent bubble — in `theme`'s colors. */
|
||||
function ThemePreview({ theme }: { theme: Theme }) {
|
||||
const colors = theme.colors;
|
||||
const previewStyles = useMemo(
|
||||
() => ({
|
||||
root: [styles.preview, { backgroundColor: colors.surface0 }],
|
||||
sidebar: [styles.previewSidebar, { backgroundColor: colors.surfaceSidebar }],
|
||||
mutedLine: { backgroundColor: colors.foregroundMuted },
|
||||
strongLine: { backgroundColor: colors.foreground },
|
||||
bubble: [styles.previewBubble, { backgroundColor: colors.accent }],
|
||||
}),
|
||||
[colors],
|
||||
);
|
||||
return (
|
||||
<View style={previewStyles.root}>
|
||||
<View style={previewStyles.sidebar}>
|
||||
<View style={[styles.previewLine, styles.sidebarLineWide, previewStyles.mutedLine]} />
|
||||
<View style={[styles.previewLine, styles.sidebarLineNarrow, previewStyles.mutedLine]} />
|
||||
<View style={[styles.previewLine, styles.sidebarLineMedium, previewStyles.mutedLine]} />
|
||||
</View>
|
||||
<View style={styles.previewMain}>
|
||||
<View style={[styles.previewTitle, previewStyles.strongLine]} />
|
||||
<View style={[styles.previewLine, styles.mainLineWide, previewStyles.mutedLine]} />
|
||||
<View style={[styles.previewLine, styles.mainLineNarrow, previewStyles.mutedLine]} />
|
||||
<View style={previewStyles.bubble} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** "System" follows the OS: light on the left half, dark on the right. */
|
||||
function SystemThemePreview() {
|
||||
return (
|
||||
<View style={styles.preview}>
|
||||
<ThemePreview theme={lightTheme} />
|
||||
<View style={styles.systemDarkHalf}>
|
||||
<View style={styles.systemDarkOffset}>
|
||||
<ThemePreview theme={darkTheme} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Columns fill the row: each cell is a fixed fraction of it, and the half-gap padding on every
|
||||
// cell plus the negative margin here leaves exactly one gap between neighbors.
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
margin: -theme.spacing[3] / 2,
|
||||
},
|
||||
cell: {
|
||||
width: { xs: "50%", md: "25%" },
|
||||
padding: theme.spacing[3] / 2,
|
||||
},
|
||||
tile: {
|
||||
padding: theme.spacing[1],
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[2],
|
||||
borderColor: "transparent",
|
||||
},
|
||||
tileHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tileSelected: {
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
previewFrame: {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
preview: {
|
||||
width: "100%",
|
||||
aspectRatio: PREVIEW_ASPECT_RATIO,
|
||||
flexDirection: "row",
|
||||
overflow: "hidden",
|
||||
},
|
||||
previewSidebar: {
|
||||
width: 36,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 8,
|
||||
gap: 5,
|
||||
},
|
||||
previewMain: {
|
||||
flex: 1,
|
||||
padding: 8,
|
||||
gap: 5,
|
||||
},
|
||||
previewLine: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
opacity: 0.6,
|
||||
},
|
||||
previewTitle: {
|
||||
width: "55%",
|
||||
height: 5,
|
||||
borderRadius: 2,
|
||||
},
|
||||
sidebarLineWide: { width: "100%" },
|
||||
sidebarLineNarrow: { width: "60%" },
|
||||
sidebarLineMedium: { width: "80%" },
|
||||
mainLineWide: { width: "90%" },
|
||||
mainLineNarrow: { width: "65%" },
|
||||
previewBubble: {
|
||||
marginTop: "auto",
|
||||
alignSelf: "flex-end",
|
||||
width: "45%",
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
systemDarkHalf: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: "50%",
|
||||
width: "50%",
|
||||
overflow: "hidden",
|
||||
},
|
||||
// A full-width dark preview shifted left by half, so only its right half shows.
|
||||
systemDarkOffset: {
|
||||
width: "200%",
|
||||
marginLeft: "-100%",
|
||||
},
|
||||
}));
|
||||
@@ -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 {
|
||||
|
||||
@@ -118,7 +118,7 @@ function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogB
|
||||
description={t("changelog.error.description")}
|
||||
testID="changelog-error"
|
||||
>
|
||||
<Button variant="outline" onPress={onRetry} testID="changelog-retry">
|
||||
<Button variant="outline" size="sm" onPress={onRetry} testID="changelog-retry">
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
|
||||
@@ -99,7 +99,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
maxHeight: "85%",
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.surface2,
|
||||
@@ -140,12 +140,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[SHEET_HEADER_CLOSE_PADDING_SCALE],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
// Without a header the close button floats over the card's top-right corner.
|
||||
floatingClose: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[4],
|
||||
right: theme.spacing[4],
|
||||
},
|
||||
searchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -236,28 +230,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
const WEB_EXIT_DURATION_MS = 160;
|
||||
const WEB_ENTER_DURATION_MS = 160;
|
||||
const WEB_ENTER_ANIMATION_NAME = "paseo-modal-enter";
|
||||
|
||||
// The desktop card fades in; the dim lands at once, so the screen behind is never seen undimmed
|
||||
// while the dialog arrives. A CSS animation runs from the card's first paint, so a dialog that opens
|
||||
// on its own, like first-run setup, never pops in. Closing fades the whole overlay. With reduced
|
||||
// motion the keyframes are never defined, so the card appears at once.
|
||||
function ensureWebEnterKeyframes() {
|
||||
if (!isWeb || typeof document === "undefined") return;
|
||||
if (document.getElementById(WEB_ENTER_ANIMATION_NAME)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = WEB_ENTER_ANIMATION_NAME;
|
||||
style.textContent = `@media (prefers-reduced-motion: no-preference) { @keyframes ${WEB_ENTER_ANIMATION_NAME} { from { opacity: 0; } to { opacity: 1; } } }`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
ensureWebEnterKeyframes();
|
||||
// The CSS animation props are not part of ViewStyle without the generated expo-env.d.ts.
|
||||
const WEB_ENTER_STYLE = {
|
||||
animationName: WEB_ENTER_ANIMATION_NAME,
|
||||
animationDuration: `${WEB_ENTER_DURATION_MS}ms`,
|
||||
animationTimingFunction: "ease-out",
|
||||
} as unknown as ViewStyle;
|
||||
|
||||
/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */
|
||||
export function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
@@ -521,27 +493,8 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet's action row: a top border, the sheet's side inset, and children spread across it.
|
||||
* The sheet draws it for `footer`; a body that splits into panes renders it at the bottom of one.
|
||||
*/
|
||||
export function SheetFooter({
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <View style={[styles.footer, style]}>{children}</View>;
|
||||
}
|
||||
|
||||
export interface AdaptiveModalSheetProps {
|
||||
/**
|
||||
* The title row with the close button. Leave it out when the body draws its own chrome, such as
|
||||
* a full-height side rail: the desktop card then floats the close button over its top-right
|
||||
* corner, and the compact sheet closes from its handle.
|
||||
*/
|
||||
header?: SheetHeader;
|
||||
header: SheetHeader;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDismiss?: () => void;
|
||||
@@ -581,7 +534,7 @@ function CompactSheetBody({
|
||||
contentStyle,
|
||||
children,
|
||||
}: {
|
||||
header?: SheetHeader;
|
||||
header: SheetHeader;
|
||||
onClose: () => void;
|
||||
testID?: string;
|
||||
footer: ReactNode;
|
||||
@@ -611,7 +564,7 @@ function CompactSheetBody({
|
||||
);
|
||||
return (
|
||||
<SheetVisibleFrame footer={footerView}>
|
||||
{header ? <SheetHeaderView header={header} onClose={onClose} testID={testID} /> : null}
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
<View style={[styles.compactStaticContent, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
@@ -659,7 +612,7 @@ export function AdaptiveModalSheet({
|
||||
const isKeyboardVisible = useKeyboardVisibility(visible);
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const footerView = useMemo(
|
||||
() => (footer ? <SheetFooter style={footerContainerStyle}>{footer}</SheetFooter> : null),
|
||||
() => (footer ? <View style={[styles.footer, footerContainerStyle]}>{footer}</View> : null),
|
||||
[footer, footerContainerStyle],
|
||||
);
|
||||
useEffect(() => {
|
||||
@@ -688,7 +641,6 @@ export function AdaptiveModalSheet({
|
||||
styles.desktopCard,
|
||||
desktopHeight != null && { height: desktopHeight },
|
||||
desktopMaxWidth != null && { maxWidth: desktopMaxWidth },
|
||||
isWeb && WEB_ENTER_STYLE,
|
||||
],
|
||||
[desktopMaxWidth, desktopHeight],
|
||||
);
|
||||
@@ -778,7 +730,7 @@ export function AdaptiveModalSheet({
|
||||
desktopHeight == null ? styles.desktopStaticContent : styles.compactStaticContent;
|
||||
const cardInner = (
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
{header ? <SheetHeaderView header={header} onClose={onClose} /> : null}
|
||||
<SheetHeaderView header={header} onClose={onClose} />
|
||||
<View style={[scrollable ? styles.desktopScrollContainer : desktopStaticStyle, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
@@ -794,15 +746,6 @@ export function AdaptiveModalSheet({
|
||||
)}
|
||||
</View>
|
||||
{footerView}
|
||||
{header ? null : (
|
||||
<View style={styles.floatingClose}>
|
||||
<SheetHeaderIconButton
|
||||
icon={X}
|
||||
accessibilityLabel={t("common.actions.close")}
|
||||
onPress={onClose}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -6,12 +6,15 @@ 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 {
|
||||
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 "./pair-link-credentials";
|
||||
import { buildDirectConnectionUri } from "./direct-connection-host";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
|
||||
@@ -136,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}`;
|
||||
@@ -391,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("");
|
||||
@@ -476,7 +505,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={t("pairing.direct.fields.useSsl")}
|
||||
accessibilityState={useTlsAccessibilityState}
|
||||
aria-checked={useTls}
|
||||
testID="direct-ssl-toggle"
|
||||
>
|
||||
<View style={checkboxStyle}>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
resolveControlInteractionStyles,
|
||||
} from "@/components/ui/control-geometry";
|
||||
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";
|
||||
|
||||
@@ -48,10 +49,18 @@ const styles = StyleSheet.create((theme) => {
|
||||
minWidth: 0,
|
||||
},
|
||||
// Boxed adornment next to the field: same height and radius as the field
|
||||
// chrome, and the shared interaction phases.
|
||||
iconButton: {
|
||||
minHeight: CONTROL_HEIGHTS.md,
|
||||
width: CONTROL_HEIGHTS.md,
|
||||
// chrome of the active form size, and the shared interaction phases.
|
||||
iconButtonSm: {
|
||||
minHeight: CONTROL_HEIGHTS.compact,
|
||||
width: CONTROL_HEIGHTS.compact,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
iconButtonMd: {
|
||||
minHeight: CONTROL_HEIGHTS.field,
|
||||
width: CONTROL_HEIGHTS.field,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -97,6 +106,7 @@ export function AddRemoteSshHostModal({
|
||||
}: AddRemoteSshHostModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const hosts = useHosts();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
|
||||
const targetRef = useRef("");
|
||||
const passwordRef = useRef("");
|
||||
@@ -183,7 +193,7 @@ export function AddRemoteSshHostModal({
|
||||
(
|
||||
state: PressableStateCallbackType & { hovered?: boolean; focused?: boolean },
|
||||
): StyleProp<ViewStyle> => [
|
||||
styles.iconButton,
|
||||
isCompact ? styles.iconButtonMd : styles.iconButtonSm,
|
||||
resolveControlInteractionStyles(
|
||||
{
|
||||
controlRest: styles.controlRest,
|
||||
@@ -199,7 +209,7 @@ export function AddRemoteSshHostModal({
|
||||
},
|
||||
),
|
||||
],
|
||||
[isSaving],
|
||||
[isCompact, isSaving],
|
||||
);
|
||||
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
|
||||
|
||||
@@ -218,6 +228,7 @@ export function AddRemoteSshHostModal({
|
||||
>
|
||||
<FormTextInput
|
||||
ref={inputRef}
|
||||
size={isCompact ? "md" : "sm"}
|
||||
testID="remote-ssh-target-input"
|
||||
accessibilityLabel={t("pairing.remoteSsh.fields.target")}
|
||||
initialValue=""
|
||||
@@ -235,6 +246,7 @@ export function AddRemoteSshHostModal({
|
||||
<View style={styles.passwordInputWrap}>
|
||||
<FormTextInput
|
||||
ref={passwordInputRef}
|
||||
size={isCompact ? "md" : "sm"}
|
||||
testID="remote-ssh-password-input"
|
||||
accessibilityLabel={t("pairing.remoteSsh.fields.password")}
|
||||
initialValue=""
|
||||
|
||||
@@ -41,6 +41,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
<View style={styles.callout}>
|
||||
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={handleUnarchive}
|
||||
disabled={!isConnected || isUnarchiving}
|
||||
|
||||
@@ -27,6 +27,7 @@ export function CommunityLinks() {
|
||||
<View style={styles.row}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={renderGitHubIcon}
|
||||
onPress={handleOpenGitHub}
|
||||
testID="community-links-github-star"
|
||||
@@ -35,6 +36,7 @@ export function CommunityLinks() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={Heart}
|
||||
onPress={handleOpenSponsor}
|
||||
testID="community-links-sponsor"
|
||||
@@ -43,6 +45,7 @@ export function CommunityLinks() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={renderDiscordIcon}
|
||||
onPress={handleOpenDiscord}
|
||||
testID="community-links-discord"
|
||||
@@ -56,9 +59,6 @@ export function CommunityLinks() {
|
||||
const styles = StyleSheet.create(() => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
// Shrinks and wraps rather than running off a narrow phone screen.
|
||||
flexShrink: 1,
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
|
||||
@@ -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,26 +1,8 @@
|
||||
import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react";
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal, PortalHost } from "@gorhom/portal";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { EdgeFade } from "@/components/ui/edge-fade";
|
||||
|
||||
const OverlayClearanceContext = createContext(0);
|
||||
const OverlayHostContext = createContext<string | null>(null);
|
||||
|
||||
/** Keep status overlays above the fade without lifting the scrolling content. */
|
||||
export function FloatingHeaderOverlay({ children }: { children: ReactNode }) {
|
||||
const hostName = useContext(OverlayHostContext);
|
||||
const active = useRetainedPanelActive();
|
||||
if (!hostName) return children;
|
||||
return active ? <Portal hostName={hostName}>{children}</Portal> : null;
|
||||
}
|
||||
|
||||
/** Clearance for stationary overlays inside this layout, measured to the header bottom. */
|
||||
export function useFloatingHeaderOverlayClearance(): number {
|
||||
return useContext(OverlayClearanceContext);
|
||||
}
|
||||
|
||||
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
|
||||
const FADE_OVERHANG = 48;
|
||||
/** Share of the fade that stays opaque, so the header's title and icons keep contrast. */
|
||||
@@ -38,24 +20,15 @@ interface FloatingHeaderLayoutProps {
|
||||
* same tree position in both modes, so toggling `floating` never remounts them.
|
||||
*/
|
||||
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
|
||||
const overlayHostName = useId();
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setHeaderHeight(Math.round(event.nativeEvent.layout.height));
|
||||
}, []);
|
||||
|
||||
const overlayClearance = floating ? headerHeight : 0;
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{floating ? null : header}
|
||||
<View style={styles.content}>
|
||||
<OverlayClearanceContext.Provider value={overlayClearance}>
|
||||
<OverlayHostContext.Provider value={floating ? overlayHostName : null}>
|
||||
{children(floating ? headerHeight : 0)}
|
||||
</OverlayHostContext.Provider>
|
||||
</OverlayClearanceContext.Provider>
|
||||
</View>
|
||||
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
|
||||
{floating ? (
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<EdgeFade
|
||||
@@ -64,19 +37,11 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
|
||||
backdrop="surfaceWorkspace"
|
||||
style={styles.fade}
|
||||
/>
|
||||
<View
|
||||
testID="floating-header"
|
||||
collapsable={false}
|
||||
pointerEvents="box-none"
|
||||
onLayout={handleHeaderLayout}
|
||||
>
|
||||
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
|
||||
{header}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.statusOverlay} pointerEvents="box-none">
|
||||
<PortalHost name={overlayHostName} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -90,13 +55,6 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
statusOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
|
||||
|
||||
// Same mark as the website's download buttons (packages/website/src/downloads.tsx).
|
||||
const APPLE_PATH =
|
||||
"M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701";
|
||||
|
||||
export function AppleIcon(props: SvgPathIconProps) {
|
||||
return <SvgPathIcon {...props} path={APPLE_PATH} viewBox="0 0 24 24" />;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
/**
|
||||
* The four-colour Play mark, the same one as the website's download buttons
|
||||
* (packages/website/src/downloads.tsx). Brand colours are fixed, so it takes no color.
|
||||
*/
|
||||
export function GooglePlayIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M3.27 2.17c-.2.21-.32.55-.32 1v17.66c0 .45.12.79.33 1l9.87-9.84z" fill="#00F076" />
|
||||
<Path
|
||||
d="m16.43 8.72-3.28 3.27 3.29 3.28 3.9-2.22c1.13-.64 1.13-1.42 0-2.06z"
|
||||
fill="#FFCE00"
|
||||
/>
|
||||
<Path d="m13.15 11.99 3.28-3.27L4.63 2.01c-.56-.32-1.04-.28-1.36.16z" fill="#00D6FF" />
|
||||
<Path d="M3.28 21.83c.32.44.8.48 1.36.16l11.8-6.72-3.29-3.28z" fill="#FF3A44" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -206,7 +206,7 @@ function ProviderErrorBannerRow({
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="xs"
|
||||
onPress={handleRetry}
|
||||
testID={`import-session-retry-${row.provider}`}
|
||||
>
|
||||
@@ -266,7 +266,7 @@ function ScopeSubtitle({
|
||||
{isScoped ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="xs"
|
||||
onPress={onShowAll}
|
||||
testID="import-session-show-all"
|
||||
style={styles.subtitleAction}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,9 +122,7 @@ 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("![") &&
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1235,7 +1235,7 @@ function ProviderErrorEmptyState({
|
||||
<ThemedAlertTriangle size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
||||
<Text style={styles.emptyStateText}>{message}</Text>
|
||||
{onRetryProvider ? (
|
||||
<Button variant="default" onPress={handleRetry} disabled={isRetryingProvider}>
|
||||
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
||||
{isRetryingProvider ? t("modelSelector.retrying") : t("modelSelector.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -9,8 +9,9 @@ import type { ProjectIconSource } from "@getpaseo/protocol/messages";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ControlSize } from "@/components/ui/control-geometry";
|
||||
import type { FieldControlSize } from "@/components/ui/control-geometry";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
import {
|
||||
@@ -49,7 +50,7 @@ export function ProjectEditSheet({
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const { pickFiles } = useFilePicker();
|
||||
const size: ControlSize = "md";
|
||||
const size: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
|
||||
const [form] = useState(() => openProjectEditForm(snapshot));
|
||||
const state = useSyncExternalStore(form.subscribe, form.getState, form.getState);
|
||||
|
||||
@@ -95,6 +96,7 @@ export function ProjectEditSheet({
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
style={styles.footerButton}
|
||||
onPress={handleClose}
|
||||
disabled={isSaving}
|
||||
@@ -103,6 +105,7 @@ export function ProjectEditSheet({
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="md"
|
||||
style={styles.footerButton}
|
||||
onPress={handleSubmit}
|
||||
disabled={!state.canSubmit}
|
||||
|
||||
@@ -89,7 +89,7 @@ function CatalogTile({ entry, width, installing, onInstall }: CatalogTileProps)
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
size="sm"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={installing}
|
||||
loading={installing}
|
||||
|
||||
@@ -222,10 +222,10 @@ function AddCustomModelSubSheet({
|
||||
/>
|
||||
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
|
||||
<View style={sheetStyles.formActions}>
|
||||
<Button variant="secondary" onPress={onClose} disabled={saving}>
|
||||
<Button variant="secondary" size="sm" onPress={onClose} disabled={saving}>
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="default" onPress={handleAdd} disabled={!canAdd || saving}>
|
||||
<Button variant="default" size="sm" onPress={handleAdd} disabled={!canAdd || saving}>
|
||||
{saving ? t("settings.providers.models.adding") : t("settings.providers.models.add")}
|
||||
</Button>
|
||||
</View>
|
||||
@@ -436,11 +436,18 @@ function renderProviderSheetFooter({
|
||||
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
|
||||
) : null}
|
||||
<View style={actionsStyle}>
|
||||
<Button variant="secondary" leftIcon={Plus} onPress={onOpenAddSheet} style={buttonStyle}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={Plus}
|
||||
onPress={onOpenAddSheet}
|
||||
style={buttonStyle}
|
||||
>
|
||||
{t("settings.providers.models.addModel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={FileText}
|
||||
onPress={onOpenDiagSheet}
|
||||
style={buttonStyle}
|
||||
@@ -449,6 +456,7 @@ function renderProviderSheetFooter({
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftIcon={modelsRefreshing ? undefined : RotateCw}
|
||||
onPress={onRefreshModels}
|
||||
disabled={modelsRefreshing}
|
||||
@@ -512,7 +520,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
|
||||
<Button variant="default" onPress={onRefresh} disabled={modelsRefreshing}>
|
||||
<Button variant="default" size="sm" onPress={onRefresh} disabled={modelsRefreshing}>
|
||||
{modelsRefreshing
|
||||
? t("settings.providers.models.retrying")
|
||||
: t("settings.providers.models.retry")}
|
||||
|
||||
@@ -145,6 +145,7 @@ export function AdaptiveRenameModal({
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
style={styles.actionButton}
|
||||
onPress={handleCancel}
|
||||
disabled={isPending}
|
||||
@@ -154,6 +155,7 @@ export function AdaptiveRenameModal({
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
style={styles.actionButton}
|
||||
onPress={handleSubmitVoid}
|
||||
disabled={submitDisabled}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useReducer, useState, type ReactNode } from "reac
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
||||
import type { ControlSize } from "@/components/ui/control-geometry";
|
||||
import type { FieldControlSize } from "@/components/ui/control-geometry";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import { SelectField, type SelectFieldOption } from "@/components/ui/select-field";
|
||||
import {
|
||||
@@ -20,7 +20,7 @@ export interface CadenceEditorProps {
|
||||
value: ScheduleCadence;
|
||||
onChange: (next: ScheduleCadence) => void;
|
||||
error?: string;
|
||||
size?: ControlSize;
|
||||
size?: FieldControlSize;
|
||||
}
|
||||
|
||||
const PRESET_OPTIONS: SelectFieldOption<string>[] = CADENCE_PRESET_OPTIONS.map((option) => ({
|
||||
|
||||
@@ -19,8 +19,9 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
|
||||
import { ComboboxItem } from "@/components/ui/combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
|
||||
import { createControlGeometry, type ControlSize } from "@/components/ui/control-geometry";
|
||||
import { createControlGeometry, type FieldControlSize } from "@/components/ui/control-geometry";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
@@ -238,7 +239,7 @@ function OpenScheduleFormSheet({
|
||||
mode,
|
||||
schedule,
|
||||
}: ScheduleFormSheetProps & { onDismiss: () => void }): ReactElement {
|
||||
const controlSize: ControlSize = "md";
|
||||
const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
|
||||
const { projects } = useProjects();
|
||||
const hostProfiles = useHosts();
|
||||
const hosts = useStoreWithEqualityFn(
|
||||
@@ -459,7 +460,7 @@ interface ScheduleFormFieldsProps {
|
||||
state: ScheduleFormState;
|
||||
providerSnapshot: ReturnType<typeof useScheduleFormProviderSnapshot>;
|
||||
agentTargetLabel: string | null;
|
||||
controlSize: ControlSize;
|
||||
controlSize: FieldControlSize;
|
||||
cadenceError: string | null;
|
||||
mutationServerId: string;
|
||||
}
|
||||
@@ -556,7 +557,7 @@ interface ScheduleTargetFieldsProps {
|
||||
state: ScheduleFormState;
|
||||
providerSnapshot: ReturnType<typeof useScheduleFormProviderSnapshot>;
|
||||
agentTargetLabel: string | null;
|
||||
controlSize: ControlSize;
|
||||
controlSize: FieldControlSize;
|
||||
mutationServerId: string;
|
||||
}
|
||||
|
||||
@@ -818,7 +819,7 @@ function ScheduleIsolationField({
|
||||
}: {
|
||||
model: ScheduleFormModel;
|
||||
state: ScheduleFormState;
|
||||
size: ControlSize;
|
||||
size: FieldControlSize;
|
||||
}): ReactElement {
|
||||
const options = useMemo<SelectFieldOption<"local" | "worktree">[]>(
|
||||
() => [
|
||||
@@ -891,7 +892,7 @@ function ScheduleAgentTargetField({
|
||||
size,
|
||||
}: {
|
||||
label: string | null;
|
||||
size: ControlSize;
|
||||
size: FieldControlSize;
|
||||
}): ReactElement {
|
||||
const fieldStyle = useMemo(
|
||||
() => [styles.readonlyField, size === "sm" ? styles.readonlyFieldSm : styles.readonlyFieldMd],
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SettingsRowHeader } from "./row-header";
|
||||
|
||||
interface SettingsDetailRowProps {
|
||||
label: string;
|
||||
/** Shown beside the label, such as a badge. */
|
||||
labelAccessory?: ReactNode;
|
||||
/** A line under the label saying what the row leads to. */
|
||||
hint?: string;
|
||||
/** What the detail is set to, so the row says it without being opened. */
|
||||
summary?: string;
|
||||
onPress: () => void;
|
||||
/** A row that cannot be opened keeps its summary and drops the chevron. */
|
||||
disabled?: boolean;
|
||||
/** Opens a page outside the app; an outward arrow replaces the chevron. */
|
||||
external?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
@@ -21,6 +14,20 @@ interface SettingsDetailRowProps {
|
||||
* A settings row that opens a detail, such as a sheet, for one setting. The label, a one-line
|
||||
* summary of the current value and a chevron pointing right. Sits inside a `SettingsCard`.
|
||||
*/
|
||||
export function SettingsDetailRow(props: SettingsDetailRowProps) {
|
||||
return <SettingsRowHeader {...props} />;
|
||||
export function SettingsDetailRow({
|
||||
label,
|
||||
summary,
|
||||
onPress,
|
||||
disabled,
|
||||
testID,
|
||||
}: SettingsDetailRowProps) {
|
||||
return (
|
||||
<SettingsRowHeader
|
||||
label={label}
|
||||
summary={summary}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ExternalLink, type ExternalLinkProps } from "@/components/ui/external-link";
|
||||
import { SettingsInfoTip } from "./settings-info-tip";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
@@ -12,12 +11,6 @@ interface SettingsSectionProps {
|
||||
* paragraph between the header and the card is wrong (docs/design.md §7).
|
||||
*/
|
||||
info?: ReactNode;
|
||||
/**
|
||||
* A page outside the app, such as the docs, shown at the header's end as "Label ↗". Its glyph
|
||||
* sits as far inside the card's edge as the title does on the other side.
|
||||
*/
|
||||
link?: Pick<ExternalLinkProps, "href" | "label" | "accessibilityLabel" | "tooltip" | "testID">;
|
||||
/** An action at the header's end, such as Refresh or Add. A link to a page is `link`. */
|
||||
trailing?: ReactNode;
|
||||
testID?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
@@ -37,7 +30,6 @@ interface SettingsSectionProps {
|
||||
export function SettingsSection({
|
||||
title,
|
||||
info,
|
||||
link,
|
||||
trailing,
|
||||
testID,
|
||||
style,
|
||||
@@ -61,11 +53,6 @@ export function SettingsSection({
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{link ? (
|
||||
<View style={styles.link}>
|
||||
<ExternalLink {...link} />
|
||||
</View>
|
||||
) : null}
|
||||
{trailing}
|
||||
</View>
|
||||
<View style={styles.content}>{children}</View>
|
||||
@@ -82,10 +69,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
// Mirrors the header's marginLeft, so the link's glyph and the title's sit on matching rails.
|
||||
link: {
|
||||
marginRight: theme.spacing[1],
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { CONTROL_HEIGHTS, type ControlSize } from "@/components/ui/control-geometry";
|
||||
|
||||
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
|
||||
import type { ContainedControlSize } from "@/components/ui/control-size";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows
|
||||
@@ -19,7 +19,7 @@ export interface SettingsRowLayout {
|
||||
title: StyleProp<TextStyle>;
|
||||
hint: StyleProp<TextStyle>;
|
||||
/** The size of the controls in the row's trailing slot. */
|
||||
controlSize: ControlSize;
|
||||
controlSize: ContainedControlSize;
|
||||
}
|
||||
|
||||
/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */
|
||||
@@ -32,24 +32,24 @@ export function useSettingsRowLayout(): SettingsRowLayout {
|
||||
row: [settingsStyles.row, styles.nestedRow],
|
||||
title: [settingsStyles.rowTitle, styles.nestedText],
|
||||
hint: [settingsStyles.rowHint, styles.nestedText],
|
||||
controlSize: "sm",
|
||||
controlSize: "xs",
|
||||
}
|
||||
: {
|
||||
row: settingsStyles.row,
|
||||
title: settingsStyles.rowTitle,
|
||||
hint: settingsStyles.rowHint,
|
||||
controlSize: "md",
|
||||
controlSize: "sm",
|
||||
},
|
||||
[nested],
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Every nested row is as tall as one holding an `sm` control, so rows with and without a
|
||||
// Every nested row is as tall as one holding an `xs` control, so rows with and without a
|
||||
// trailing button line up.
|
||||
nestedRow: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: CONTROL_HEIGHTS.sm + theme.spacing[2] * 2,
|
||||
minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2,
|
||||
},
|
||||
nestedText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ExternalLinkLabel } from "@/components/ui/external-link";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -16,18 +15,12 @@ type HoverState = PressableStateCallbackType & { hovered?: boolean };
|
||||
|
||||
export interface SettingsRowHeaderProps {
|
||||
label: string;
|
||||
/** Shown beside the label, such as a badge. */
|
||||
labelAccessory?: ReactNode;
|
||||
/** A line under the label saying what the row leads to. */
|
||||
hint?: string;
|
||||
/** A short status shown before the chevron. */
|
||||
summary?: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
/** Turns the chevron down, for a row whose children are revealed in place. */
|
||||
expanded?: boolean;
|
||||
/** The row leaves the app, such as to a docs page: the summary and an outward arrow are drawn as an external link in place of the chevron. */
|
||||
external?: boolean;
|
||||
accessibilityState?: AccessibilityState;
|
||||
/** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */
|
||||
webAccessibilityProps?: Record<string, unknown> | null;
|
||||
@@ -41,13 +34,10 @@ export interface SettingsRowHeaderProps {
|
||||
*/
|
||||
export function SettingsRowHeader({
|
||||
label,
|
||||
labelAccessory,
|
||||
hint,
|
||||
summary,
|
||||
onPress,
|
||||
disabled,
|
||||
expanded = false,
|
||||
external = false,
|
||||
accessibilityState,
|
||||
webAccessibilityProps,
|
||||
testID,
|
||||
@@ -68,7 +58,7 @@ export function SettingsRowHeader({
|
||||
return (
|
||||
<PressHighlight
|
||||
{...webAccessibilityProps}
|
||||
accessibilityRole={external ? "link" : "button"}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={summary ? `${label}, ${summary}` : label}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={disabled}
|
||||
@@ -80,35 +70,20 @@ export function SettingsRowHeader({
|
||||
{({ hovered }: HoverState) => (
|
||||
<>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
{labelAccessory ? (
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
{labelAccessory}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
)}
|
||||
{hint ? <Text style={layout.hint}>{hint}</Text> : null}
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
</View>
|
||||
{external ? (
|
||||
// Drawn the same as a section header's docs link, so the two read as one thing.
|
||||
<ExternalLinkLabel hovered={hovered}>{summary}</ExternalLinkLabel>
|
||||
) : (
|
||||
<>
|
||||
{summary ? (
|
||||
<Text style={styles.summary} numberOfLines={1}>
|
||||
{summary}
|
||||
</Text>
|
||||
) : null}
|
||||
{disabled ? null : (
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevron
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
{summary ? (
|
||||
<Text style={styles.summary} numberOfLines={1}>
|
||||
{summary}
|
||||
</Text>
|
||||
) : null}
|
||||
{disabled ? null : (
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevron
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -117,7 +92,6 @@ export function SettingsRowHeader({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
labelRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
summary: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { Animated, View } from "react-native";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { projectIconRadius } from "@/components/project-icon-view";
|
||||
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
|
||||
|
||||
const PROJECT_ICON_SIZE = 16;
|
||||
|
||||
@@ -12,6 +11,17 @@ const ROW_KEYS_BY_SECTION: readonly (readonly string[])[] = SECTION_OPACITIES.ma
|
||||
[0, 1, 2].map((r) => `skeleton-row-${sIdx}-${r}`),
|
||||
);
|
||||
|
||||
function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp<ViewStyle> }) {
|
||||
const opacity = pulse.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.4, 0.8],
|
||||
});
|
||||
|
||||
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
|
||||
|
||||
return <Animated.View style={pulseStyle} />;
|
||||
}
|
||||
|
||||
function SkeletonSection({
|
||||
pulse,
|
||||
sectionOpacity,
|
||||
@@ -47,7 +57,27 @@ function SkeletonSection({
|
||||
}
|
||||
|
||||
export function SidebarAgentListSkeleton() {
|
||||
const pulse = useSkeletonPulse();
|
||||
const pulse = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulse, {
|
||||
toValue: 0,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [pulse]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function SidebarFilterEmptyState() {
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
size="xs"
|
||||
style={styles.action}
|
||||
textStyle={styles.actionText}
|
||||
onPress={clearFilters}
|
||||
@@ -84,7 +84,7 @@ export function SidebarProjectEmptyState({
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
size="xs"
|
||||
leftIcon={Plus}
|
||||
style={styles.action}
|
||||
textStyle={styles.actionText}
|
||||
@@ -94,7 +94,7 @@ export function SidebarProjectEmptyState({
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="xs"
|
||||
leftIcon={Import}
|
||||
style={styles.action}
|
||||
textStyle={styles.actionText}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -93,7 +93,6 @@ function NativeTerminalEmulator({
|
||||
testId = "terminal-surface",
|
||||
xtermTheme = DEFAULT_XTERM_THEME,
|
||||
scrollbackLines,
|
||||
characterWidths,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
keyboardInset = 0,
|
||||
@@ -374,7 +373,6 @@ function NativeTerminalEmulator({
|
||||
rows: size.rows,
|
||||
cols: size.cols,
|
||||
scrollbackLines,
|
||||
characterWidths,
|
||||
});
|
||||
terminalRef.current = terminal;
|
||||
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
|
||||
@@ -389,7 +387,6 @@ function NativeTerminalEmulator({
|
||||
resetInputModeTracker,
|
||||
resolveTerminalScreen,
|
||||
scrollbackLines,
|
||||
characterWidths,
|
||||
clearSelection,
|
||||
]);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user