diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d2d489f09..7c2a397170 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,10 @@ jobs: if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} run: npm run lint + - name: Test lint rules + if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} + run: node --test scripts/oxlint/paseo-plugin.test.mjs + - name: Build relay if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} run: npm run build:relay diff --git a/.oxlintrc.json b/.oxlintrc.json index 4582602a97..f5078649c9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,6 +5,7 @@ }, "ignorePatterns": [".dev/**"], "plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"], + "jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"], "categories": { "correctness": "error", "suspicious": "error", @@ -356,6 +357,117 @@ "no-restricted-imports": "off" } }, + { + "files": ["packages/app/src/**/*.{ts,tsx}"], + "rules": { + "paseo/no-raw-pressable": "error" + } + }, + // Press feedback is owned by the primitives in components/ui. Raw pressable burn-down: + // 94 files. They may only leave this list. + { + "files": [ + "packages/app/src/components/ui/**/*.{ts,tsx}", + "packages/app/src/**/*.{test,spec}.{ts,tsx}", + "packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx", + "packages/app/src/agent-skills/selection-sheet.tsx", + "packages/app/src/agent-stream/chat-outline/rail.web.tsx", + "packages/app/src/agent-stream/view.tsx", + "packages/app/src/app/pair-scan.tsx", + "packages/app/src/changelog/internal/changelog-sheet.tsx", + "packages/app/src/command-center/command-center.tsx", + "packages/app/src/components/adaptive-modal-sheet.tsx", + "packages/app/src/components/add-host-method-modal.tsx", + "packages/app/src/components/add-host-modal.tsx", + "packages/app/src/components/add-project-flow.tsx", + "packages/app/src/components/add-remote-ssh-host-modal.tsx", + "packages/app/src/components/agent-list.tsx", + "packages/app/src/components/app-diagnostic-sheet.tsx", + "packages/app/src/components/attachment-lightbox.tsx", + "packages/app/src/components/attachment-pill.tsx", + "packages/app/src/components/combined-model-selector.tsx", + "packages/app/src/components/compact-explorer-sidebar.tsx", + "packages/app/src/components/context-window-meter.tsx", + "packages/app/src/components/desktop/window-controls.tsx", + "packages/app/src/components/dictation-controls.tsx", + "packages/app/src/components/download-toast.tsx", + "packages/app/src/components/file-explorer-pane.tsx", + "packages/app/src/components/headers/back-header.tsx", + "packages/app/src/components/highlighted-code-block.tsx", + "packages/app/src/components/hosts/host-filter.tsx", + "packages/app/src/components/hosts/host-picker.tsx", + "packages/app/src/components/import-session-sheet.tsx", + "packages/app/src/components/left-sidebar.tsx", + "packages/app/src/components/markdown/fence/mermaid/host.native.tsx", + "packages/app/src/components/markdown/fence/mermaid/host.web.tsx", + "packages/app/src/components/message.tsx", + "packages/app/src/components/model-browser.tsx", + "packages/app/src/components/plan-card.tsx", + "packages/app/src/components/provider-diagnostic-sheet.tsx", + "packages/app/src/components/question-form-card.tsx", + "packages/app/src/components/realtime-voice-overlay.tsx", + "packages/app/src/components/schedules/schedule-row.tsx", + "packages/app/src/components/settings/collapsible-row.tsx", + "packages/app/src/components/settings/headings/settings-info-tip.tsx", + "packages/app/src/components/sidebar-callout.tsx", + "packages/app/src/components/sidebar-resize-handle.tsx", + "packages/app/src/components/sidebar-workspace-list.tsx", + "packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx", + "packages/app/src/components/sidebar/sidebar-header-row.tsx", + "packages/app/src/components/sidebar/sidebar-section-header.tsx", + "packages/app/src/components/sidebar/sidebar-status-list.tsx", + "packages/app/src/components/sidebar/workspace-meta-row/index.tsx", + "packages/app/src/components/terminal-copy-paste-actions.tsx", + "packages/app/src/components/terminal-emulator-native-grid.native.tsx", + "packages/app/src/components/terminal-pane.tsx", + "packages/app/src/components/toast-host.tsx", + "packages/app/src/components/tool-call-sheet.tsx", + "packages/app/src/components/welcome-screen.tsx", + "packages/app/src/components/workspace-hover-card.tsx", + "packages/app/src/components/zoomable-viewport/toolbar.tsx", + "packages/app/src/composer/agent-controls/index.tsx", + "packages/app/src/composer/diff-stat-pill.tsx", + "packages/app/src/composer/dock/internal/background.native.tsx", + "packages/app/src/composer/draft/import-pill.tsx", + "packages/app/src/composer/index.tsx", + "packages/app/src/composer/tracks.tsx", + "packages/app/src/desktop/browser/pane/index.electron.tsx", + "packages/app/src/git/actions-split-button.tsx", + "packages/app/src/git/commits-section/commit-row.tsx", + "packages/app/src/git/commits-section/commits-section.tsx", + "packages/app/src/git/diff-pane.tsx", + "packages/app/src/git/forges/gitlab.view.tsx", + "packages/app/src/git/pull-request-panel/checks-section.tsx", + "packages/app/src/git/pull-request-panel/pane.tsx", + "packages/app/src/git/pull-request-panel/section-kit.tsx", + "packages/app/src/hosts/host-chooser.tsx", + "packages/app/src/mobile-panels/presentation.tsx", + "packages/app/src/panels/new-tab-panel.tsx", + "packages/app/src/panels/setup-panel.tsx", + "packages/app/src/plugins/buttons/view.tsx", + "packages/app/src/review/surface.tsx", + "packages/app/src/screens/open-project-screen.tsx", + "packages/app/src/screens/project-settings-screen.tsx", + "packages/app/src/screens/projects-screen.tsx", + "packages/app/src/screens/settings-screen.tsx", + "packages/app/src/screens/settings/host-appearance-section.tsx", + "packages/app/src/screens/settings/host-page.tsx", + "packages/app/src/screens/settings/providers-section.tsx", + "packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx", + "packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx", + "packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx", + "packages/app/src/screens/workspace/workspace-tab-presentation.tsx", + "packages/app/src/tool-calls/detail-level/overview/sheet.tsx", + "packages/app/src/usage/sidebar-item.tsx", + "packages/app/src/usage/window-bar.tsx", + "packages/app/src/workspace-labels/manager-modal.tsx", + "packages/app/src/workspace-labels/swatch.tsx", + "packages/app/src/workspace/open-in-editor/button.tsx" + ], + "rules": { + "paseo/no-raw-pressable": "off" + } + }, { "files": ["**/e2e/**/fixtures.ts"], "rules": { diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b4568cb0..af0f7904ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # Changelog -## 0.12.0-beta.3 - 2026-10-18 +## 0.12.0 - 2026-10-19 -Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes. +Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes. **Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds @@ -29,9 +29,10 @@ Background workspaces, a new Markdown renderer with selection across paragraphs - Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129)) - Changed Command Center to rank workspaces and agents by match and then recency, and to show timestamps, project icons, and highlighted matches ([#7277](https://github.com/getpaseo/paseo/pull/7277)) - Added each Paseo subagent's model, thinking, mode, and context under its title in the subagents list ([#7290](https://github.com/getpaseo/paseo/pull/7290)) +- Made subagent rows in the Subagents panel taller on phones, and gave their archive and detach buttons the standard press highlight ([#7364](https://github.com/getpaseo/paseo/pull/7364)) - Changed Paseo tool calls in chats to lead with the action and its context, such as Send message · the first line of the prompt ([#7283](https://github.com/getpaseo/paseo/pull/7283)) - Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239)) -- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8), [#7342](https://github.com/getpaseo/paseo/pull/7342)) - Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157)) - Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242)) - Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells)) @@ -46,6 +47,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs - Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel)) - Fixed follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri)) - Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso)) +- Fixed late-loading composer plugin pills covering Copy and Fork after reopening a chat ([#7341](https://github.com/getpaseo/paseo/pull/7341) by [@sholiday](https://github.com/sholiday)) ### Fixed @@ -59,6 +61,8 @@ Background workspaces, a new Markdown renderer with selection across paragraphs - Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695)) - Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo)) - Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed the project's New workspace button choosing the first host instead of the active workspace's host ([#7308](https://github.com/getpaseo/paseo/pull/7308)) +- Fixed the composer staying focused after switching tabs with keyboard shortcuts in touch browsers ([#7352](https://github.com/getpaseo/paseo/pull/7352)) - Fixed the daemon running out of memory when several directory searches overlap on a large folder tree ([#7265](https://github.com/getpaseo/paseo/pull/7265)) - Fixed dragging the Explorer edge stuttering with several panes open ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8)) - Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379)) @@ -68,6 +72,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs - Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi)) - Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet)) - Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka)) +- Fixed resumed Codex goals appearing idle and refusing Stop after a continuation ([#7339](https://github.com/getpaseo/paseo/pull/7339) by [@hemaaanth](https://github.com/hemaaanth)) - Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone)) - Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger)) - Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons)) @@ -99,9 +104,13 @@ Background workspaces, a new Markdown renderer with selection across paragraphs - Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542)) - Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa)) - Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC)) -- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717)) +- Fixed MCP terminal key commands typing arrow and modifier key names instead of sending the keys ([#7343](https://github.com/getpaseo/paseo/pull/7343) by [@Aling33](https://github.com/Aling33)) +- Fixed CJK text gaining spaces between characters after returning to a workspace in the native terminal ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717)) - Fixed copying Korean text from the terminal on iOS and Android adding spaces between characters or dropping the first one ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman)) +- Fixed long-pressing a Korean word in the terminal on iOS and Android selecting only one character instead of the whole word ([#7353](https://github.com/getpaseo/paseo/pull/7353)) - Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin)) +- Fixed the Hub connection never reconnecting after a silent network failure ([#7315](https://github.com/getpaseo/paseo/pull/7315)) +- Fixed remote connections failing on slow networks when another resolved address is unreachable ([#7336](https://github.com/getpaseo/paseo/pull/7336)) - Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip)) - Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877)) - Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang)) @@ -158,7 +167,10 @@ Background workspaces, a new Markdown renderer with selection across paragraphs - Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter)) - Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq)) - Fixed usage cards losing their last values and showing "Updated" when a refresh fails ([#7246](https://github.com/getpaseo/paseo/pull/7246)) +- Fixed Usage merging distinct Cursor accounts into one card ([#7344](https://github.com/getpaseo/paseo/pull/7344)) - Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy)) +- Fixed generic icons for Devin, Hermes, Kiro, and Antigravity in provider lists ([#7330](https://github.com/getpaseo/paseo/pull/7330) by [@OMEG-Lu](https://github.com/OMEG-Lu), [@kevenhu001-cyber](https://github.com/kevenhu001-cyber)) +- Fixed sidebar setup buttons overflowing the card with long translated labels ([#7312](https://github.com/getpaseo/paseo/pull/7312) by [@gentslava](https://github.com/gentslava)) ## 0.11.4 - 2026-10-15 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 920c2fb466..92d3423036 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,8 +1,8 @@ # 更新日志 -## 0.12.0-beta.3 - 2026-10-18 +## 0.12.0 - 2026-10-19 -后台工作区、新的 Markdown 渲染器(手机上可跨段落选择文本)、重新设计的手机布局,以及近 100 项修复。 +后台工作区、新的 Markdown 渲染器(手机上可跨段落选择文本)、重新设计的手机布局,以及超过 120 项修复。 **升级前须知**:`paseo run --background` 已弃用,请改用 `--no-wait`;当 daemon 在 15 秒内未确认时,`paseo permit allow` 和 `deny` 现在会以非零状态退出 @@ -29,9 +29,10 @@ - 手机上的 Settings 改为大图块,并分为 App 和 Host 两组 ([#7129](https://github.com/getpaseo/paseo/pull/7129)) - Command Center 现在按匹配度和最近使用对工作区和 Agent 排序,并显示时间戳、项目图标和高亮的匹配内容 ([#7277](https://github.com/getpaseo/paseo/pull/7277)) - 子 Agent 列表中每个 Paseo 子 Agent 的标题下方新增其模型、思考、模式和上下文 ([#7290](https://github.com/getpaseo/paseo/pull/7290)) +- 手机上 Subagents 面板中的子 Agent 行更高了,其归档和分离按钮也使用了标准的按压高亮 ([#7364](https://github.com/getpaseo/paseo/pull/7364)) - 聊天中的 Paseo 工具调用现在以操作及其上下文开头,例如 Send message · 提示词的第一行 ([#7283](https://github.com/getpaseo/paseo/pull/7283)) - 手机上的 New workspace 表单改为全宽字段,Import session 移到了标题栏 ([#7239](https://github.com/getpaseo/paseo/pull/7239)) -- 减少了回复流式输出时长聊天的渲染工作量 ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 减少了回复流式输出时长聊天的渲染工作量 ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8), [#7342](https://github.com/getpaseo/paseo/pull/7342)) - 重新打开侧边栏的速度提升约 45% ([#7157](https://github.com/getpaseo/paseo/pull/7157)) - 应用重新连接后,项目和工作区加载更快 ([#7242](https://github.com/getpaseo/paseo/pull/7242)) - 一个 Agent 通过 CLI 或 MCP 发给另一个 Agent 的消息现在会引导其正在进行的回合,而不是中断它 ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells)) @@ -46,6 +47,7 @@ - 修复了 `paseo plugin reload` 在重新加载仍在进行时因 60 秒超时而失败的问题 ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel)) - 修复了插件 ACP Agent 运行期间发送的后续消息失败并提示 "Provider does not support prompt.steer" 的问题 ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri)) - 修复了 Find 在时间线插件绘制的提示词文本中找不到匹配的问题 ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso)) +- 修复了重新打开聊天后,延迟加载的 Composer 插件胶囊遮挡 Copy 和 Fork 的问题 ([#7341](https://github.com/getpaseo/paseo/pull/7341) by [@sholiday](https://github.com/sholiday)) ### 修复 @@ -59,6 +61,8 @@ - 修复了在 Appearance 中输入不完整的字体名称会破坏应用样式的问题 ([#6695](https://github.com/getpaseo/paseo/pull/6695)) - 修复了隐藏窗口没有渲染任何帧时 Linux 桌面窗口始终不出现的问题 ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo)) - 修复了同一主机上另一个 Agent 正在流式输出时,打开其他 Agent 或工作区会卡住的问题 ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 修复了项目的新建工作区按钮选中第一个主机,而不是当前工作区所在主机的问题 ([#7308](https://github.com/getpaseo/paseo/pull/7308)) +- 修复了在触屏浏览器中用键盘快捷键切换标签页后,Composer 仍保持聚焦的问题 ([#7352](https://github.com/getpaseo/paseo/pull/7352)) - 修复了在大型目录树上同时进行多个目录搜索时 daemon 内存耗尽的问题 ([#7265](https://github.com/getpaseo/paseo/pull/7265)) - 修复了打开多个窗格时拖动 Explorer 边缘卡顿的问题 ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8)) - 修复了 Provider 拒绝第一条提示词时,创建 Agent 显示错误和重试草稿的问题 ([#6379](https://github.com/getpaseo/paseo/pull/6379)) @@ -68,6 +72,7 @@ - 修复了子 Agent 很快完成第一个回合时父 Agent 收不到完成通知的问题 ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi)) - 修复了子 Agent 的任务被停止或重新加载时父 Agent 被告知子 Agent 已完成的问题 ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet)) - 修复了包含 U+2028 或 U+2029 的 Codex 聊天在 Node 24 上无法重新打开的问题 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka)) +- 修复了恢复的 Codex 目标在继续执行后显示为空闲、且无法停止的问题 ([#7339](https://github.com/getpaseo/paseo/pull/7339) by [@hemaaanth](https://github.com/hemaaanth)) - 修复了 macOS 上 Add Project 的目录搜索进入 `~/Library` 后导致 daemon 挂起的问题 ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone)) - 修复了保存的模式不被 Provider 提供时 New workspace 失败并提示 "Invalid mode" 的问题 ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger)) - 修复了回答 OpenCode v2 的多选问题失败并提示 "Invalid answer" 的问题 ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons)) @@ -99,9 +104,13 @@ - 修复了新添加到 `paseo.json` 的服务在 daemon 重启之前无法启动的问题 ([#6542](https://github.com/getpaseo/paseo/pull/6542)) - 修复了服务只监听 IPv6 localhost 时工作区服务 URL 返回 502 的问题 ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa)) - 修复了命令缺失或立即退出时终端无法关闭的问题 ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC)) -- 修复了返回工作区后 CJK 终端文本的字符之间多出空格的问题 ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717)) +- 修复了 MCP 终端按键命令输入方向键和修饰键的名称,而不是发送这些按键的问题 ([#7343](https://github.com/getpaseo/paseo/pull/7343) by [@Aling33](https://github.com/Aling33)) +- 修复了返回工作区后,原生终端中 CJK 文本的字符之间多出空格的问题 ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717)) - 修复了在 iOS 和 Android 上从终端复制韩文文本时字符之间多出空格或丢失第一个字符的问题 ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman)) +- 修复了在 iOS 和 Android 上长按终端中的韩文单词时只选中一个字符、而不是整个单词的问题 ([#7353](https://github.com/getpaseo/paseo/pull/7353)) - 修复了自托管的 Web 应用通过 80 或 443 端口上的反向代理连接失败的问题 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin)) +- 修复了网络静默故障后 Hub 连接再也不会重连的问题 ([#7315](https://github.com/getpaseo/paseo/pull/7315)) +- 修复了另一个解析出的地址不可达时,远程连接在慢速网络上失败的问题 ([#7336](https://github.com/getpaseo/paseo/pull/7336)) - 修复了助手回复中包含空格或非 ASCII 字符的文件链接以 ENOENT 失败的问题 ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip)) - 修复了 Agent 回复中路径包含空格或非 ASCII 字符的本地图片无法加载的问题 ([#6877](https://github.com/getpaseo/paseo/pull/6877)) - 修复了 Markdown 文件预览不显示通过相对路径引用的图片的问题 ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang)) @@ -158,7 +167,10 @@ - 修复了在内存少于 15 GB 的 Mac 上 `nix build .#desktop` 内存耗尽的问题 ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter)) - 修复了 Refresh all 之后用量 Provider 的位置互换的问题 ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq)) - 修复了刷新失败时用量卡片丢失最近的值并显示 "Updated" 的问题 ([#7246](https://github.com/getpaseo/paseo/pull/7246)) +- 修复了用量把不同的 Cursor 账号合并到同一张卡片中的问题 ([#7344](https://github.com/getpaseo/paseo/pull/7344)) - 修复了 OpenCode Go 用量图标在深色主题上几乎看不见的问题 ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy)) +- 修复了 Provider 列表中 Devin、Hermes、Kiro 和 Antigravity 显示通用图标的问题 ([#7330](https://github.com/getpaseo/paseo/pull/7330) by [@OMEG-Lu](https://github.com/OMEG-Lu), [@kevenhu001-cyber](https://github.com/kevenhu001-cyber)) +- 修复了翻译后的标签较长时,侧边栏中的设置按钮溢出卡片的问题 ([#7312](https://github.com/getpaseo/paseo/pull/7312) by [@gentslava](https://github.com/gentslava)) ## 0.11.4 - 2026-10-15 diff --git a/docs/design.md b/docs/design.md index e21270c85d..ce45577b69 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 ` )} @@ -357,7 +363,10 @@ export function KeyboardShortcutsSection() { ); const saveCapture = useCallback(() => { - if (capturingBindingId === null || capturedCombos.length === 0) { + if ( + capturingBindingId === null || + !isShortcutCaptureComplete(capturingBindingId, capturedCombos) + ) { return; } void setOverride(capturingBindingId, capturedCombos.join(" ")); diff --git a/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx b/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx index 7952a2b1ee..21633e7a10 100644 --- a/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx +++ b/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, type ReactElement } from "react"; +import { useCallback, useMemo, type ComponentType, type ReactElement } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; @@ -26,7 +26,7 @@ import { type SidebarNavItem, type SidebarSection, } from "@/sidebar-nav/model"; -import { resolvePluginIcon } from "@/plugins/icons"; +import { pluginIconComponent } from "@/plugins/icons"; import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; import { settingsStyles } from "@/styles/settings"; import { ICON_SIZE, type Theme } from "@/styles/theme"; @@ -50,15 +50,17 @@ const BUILTIN_ICONS: Record = { /** Plugin items register no icon, so they share this one; a legacy `addSidebarItem` keeps its own. */ const PLUGIN_ICON = Blocks; -function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) { +type NavIconComponent = ComponentType<{ size: number; color: string }>; + +function NavIcon({ Icon, color = "" }: { Icon: NavIconComponent; color?: string }) { return ; } const ThemedNavIcon = withUnistyles(NavIcon); -function navItemIcon(item: SidebarNavItem): LucideIcon { +function navItemIcon(item: SidebarNavItem): NavIconComponent { if (item.kind === "builtin") return BUILTIN_ICONS[item.id]; - return item.group.kind === "legacy" ? resolvePluginIcon(item.group.icon) : PLUGIN_ICON; + return item.group.kind === "legacy" ? pluginIconComponent(item.group.icon) : PLUGIN_ICON; } function navItemLabel(t: TFunction, item: SidebarNavItem): string { diff --git a/packages/app/src/screens/workspace/explorer-sidebar.tsx b/packages/app/src/screens/workspace/explorer-sidebar.tsx index 175be3695b..e39106b65b 100644 --- a/packages/app/src/screens/workspace/explorer-sidebar.tsx +++ b/packages/app/src/screens/workspace/explorer-sidebar.tsx @@ -1,9 +1,9 @@ import { useCallback, useMemo, type ReactNode } from "react"; import { View } from "react-native"; +import { useWorkspaceTabDrag } from "@/components/workspace-tab-drag-context"; import { StyleSheet } from "react-native-unistyles"; import { RetainedPanel } from "@/components/retained-panel"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import type { TabDropPreview } from "@/components/split-container-tab-drop-preview"; import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host"; import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; import { @@ -39,8 +39,6 @@ interface ExplorerSidebarDockProps extends Pick< normalizedWorkspaceId: string; isWorkspaceFocused: boolean; closingTabIds: Set; - activeDragTabId: string | null; - tabDropPreview: TabDropPreview | null; onSelectTab: (paneId: string, tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; @@ -59,8 +57,6 @@ export function ExplorerSidebarDock({ normalizedWorkspaceId, isWorkspaceFocused, closingTabIds, - activeDragTabId, - tabDropPreview, onSelectTab, onCloseTab, onCreateNewTab, @@ -80,6 +76,7 @@ export function ExplorerSidebarDock({ buildPaneContentModel, headerAction, }: ExplorerSidebarDockProps) { + const { activeDragTabId, tabDropPreview, externalDndContext } = useWorkspaceTabDrag(); const paneState = useMemo(() => deriveWorkspacePaneState({ pane, tabs: uiTabs }), [pane, uiTabs]); const tabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]); const activeTabId = paneState.activeTabId; @@ -144,7 +141,7 @@ export function ExplorerSidebarDock({ onCloseOtherTabs={handleCloseOtherTabs} focusModeEnabled={false} onExitFocusMode={onExitFocusMode} - externalDndContext + externalDndContext={externalDndContext} paneId={pane.id} tabs={tabItems} normalizedServerId={normalizedServerId} diff --git a/packages/app/src/screens/workspace/explorer-sidebar/frame-props.ts b/packages/app/src/screens/workspace/explorer-sidebar/frame-props.ts new file mode 100644 index 0000000000..d99686cd7f --- /dev/null +++ b/packages/app/src/screens/workspace/explorer-sidebar/frame-props.ts @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +export interface ExplorerFrameProps { + children: ReactNode; + presentation: "overlay" | "dock"; + persistenceKey: string; + containerWidth: number; + isOpen: boolean; + isWorkspaceActive: boolean; + surfaceColor?: string; +} diff --git a/packages/app/src/screens/workspace/explorer-sidebar/frame.tsx b/packages/app/src/screens/workspace/explorer-sidebar/frame.tsx new file mode 100644 index 0000000000..f166233a2e --- /dev/null +++ b/packages/app/src/screens/workspace/explorer-sidebar/frame.tsx @@ -0,0 +1,68 @@ +import { useCallback } from "react"; +import { View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + FileExplorerCloseGestureContext, + useCloseFileExplorerGesture, +} from "@/mobile-panels/gestures"; +import { useIsMobilePanelActive } from "@/mobile-panels/provider"; +import { MobilePanelOverlay } from "@/mobile-panels/presentation"; +import { RetainedPanelActivity } from "@/components/retained-panel"; +import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { useExplorerGeometry } from "./geometry"; +import type { ExplorerFrameProps } from "./frame-props"; + +function ExplorerFrameBase({ + children, + surfaceColor, + presentation, + isOpen, + isWorkspaceActive, + persistenceKey, + containerWidth, +}: ExplorerFrameProps) { + const overlay = presentation === "overlay"; + const settledOpen = useIsMobilePanelActive("file-explorer"); + const active = isWorkspaceActive && (overlay ? settledOpen : isOpen); + const resizeEnabled = isWorkspaceActive && !overlay && isOpen; + const { gesture, gestureRef } = useCloseFileExplorerGesture(isWorkspaceActive && overlay); + const storedWidth = useWorkspaceLayoutStore( + (state) => state.explorerSidebarWidthByWorkspace[persistenceKey], + ); + const resize = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar); + const onResize = useCallback( + (width: number) => resize(persistenceKey, width), + [persistenceKey, resize], + ); + const geometry = useExplorerGeometry({ + width: resolveExplorerSidebarWidth({ requestedWidth: storedWidth, containerWidth }), + containerWidth, + overlay, + resizeEnabled, + onResize, + }); + return ( + + + + + {resizeEnabled ? geometry.resizeHandle : null} + {children} + + + + + ); +} +export const ExplorerFrame = withUnistyles(ExplorerFrameBase, (theme) => ({ + surfaceColor: theme.colors.surfaceSidebar, +})); +const styles = StyleSheet.create({ content: { flex: 1, minHeight: 0 } }); diff --git a/packages/app/src/screens/workspace/explorer-sidebar/geometry-types.ts b/packages/app/src/screens/workspace/explorer-sidebar/geometry-types.ts new file mode 100644 index 0000000000..975dc721bd --- /dev/null +++ b/packages/app/src/screens/workspace/explorer-sidebar/geometry-types.ts @@ -0,0 +1,15 @@ +import type { ComponentProps, ReactNode } from "react"; +import type { StyleProp, ViewStyle } from "react-native"; +import type { MobilePanelOverlay } from "@/mobile-panels/presentation"; +export interface ExplorerGeometryInput { + width: number; + containerWidth: number; + overlay: boolean; + resizeEnabled: boolean; + onResize: (width: number) => void; +} +export interface ExplorerGeometry { + frameStyle: ComponentProps["frameStyle"]; + panelStyle: StyleProp; + resizeHandle: ReactNode; +} diff --git a/packages/app/src/screens/workspace/explorer-sidebar/geometry.tsx b/packages/app/src/screens/workspace/explorer-sidebar/geometry.tsx new file mode 100644 index 0000000000..4714467985 --- /dev/null +++ b/packages/app/src/screens/workspace/explorer-sidebar/geometry.tsx @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Gesture } from "react-native-gesture-handler"; +import { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; +import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; +import { + SIDEBAR_RESIZE_ACTIVATION_OFFSET, + SIDEBAR_RESIZE_FAIL_OFFSET, +} from "@/components/sidebar-resize-handle-layout"; +import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout"; +import type { ExplorerGeometryInput, ExplorerGeometry } from "./geometry-types"; +export function useExplorerGeometry({ + width, + containerWidth, + overlay, + resizeEnabled, + onResize, +}: ExplorerGeometryInput): ExplorerGeometry { + const insets = useSafeAreaInsets(); + const resizeWidth = useSharedValue(width); + const startWidth = useSharedValue(width); + const [resizePressed, setResizePressed] = useState(false); + useEffect(() => { + resizeWidth.value = width; + }, [resizeWidth, width]); + + const showResizeGrip = useCallback(() => setResizePressed(true), []); + const hideResizeGrip = useCallback(() => setResizePressed(false), []); + const resizeGesture = useMemo( + () => + Gesture.Pan() + .enabled(resizeEnabled) + .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) + .onBegin(() => scheduleOnRN(showResizeGrip)) + .activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET]) + .failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]) + .onStart((event) => { + startWidth.value = width + event.translationX; + resizeWidth.value = width; + }) + .onUpdate((event) => { + resizeWidth.value = resolveExplorerSidebarWidth({ + requestedWidth: startWidth.value - event.translationX, + containerWidth, + }); + }) + .onEnd(() => runOnJS(onResize)(resizeWidth.value)) + .onFinalize(() => scheduleOnRN(hideResizeGrip)), + [ + resizeEnabled, + onResize, + containerWidth, + hideResizeGrip, + resizeWidth, + showResizeGrip, + startWidth, + width, + ], + ); + // Reanimated retains the last animated value when its style is removed. Keep this + // style attached across folding and explicitly size overlays to the workspace viewport. + const animatedWidthStyle = useAnimatedStyle( + () => ({ + width: overlay ? containerWidth : resizeWidth.value, + }), + [containerWidth, overlay], + ); + return { + frameStyle: animatedWidthStyle, + panelStyle: { + paddingTop: insets.top + (overlay ? HEADER_TOP_PADDING_MOBILE : 0), + paddingLeft: overlay ? insets.left : 0, + paddingRight: overlay ? insets.right : 0, + }, + resizeHandle: ( + + ), + }; +} diff --git a/packages/app/src/screens/workspace/explorer-sidebar/geometry.web.tsx b/packages/app/src/screens/workspace/explorer-sidebar/geometry.web.tsx new file mode 100644 index 0000000000..07e115923d --- /dev/null +++ b/packages/app/src/screens/workspace/explorer-sidebar/geometry.web.tsx @@ -0,0 +1,58 @@ +import { useCallback, useState } from "react"; +import { View, StyleSheet } from "react-native"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; +import { ResizeHandle } from "@/components/resize-handle"; +import { + resolveExplorerSidebarDockSizes, + resolveExplorerSidebarWidth, +} from "@/components/explorer-sidebar-layout"; +import type { ExplorerGeometryInput, ExplorerGeometry } from "./geometry-types"; +export function useExplorerGeometry({ + width, + containerWidth, + onResize, + overlay, +}: ExplorerGeometryInput): ExplorerGeometry { + const [previewWidth, setPreviewWidth] = useState(null); + const requestedWidth = previewWidth ?? width; + const sizes = resolveExplorerSidebarDockSizes({ requestedWidth, containerWidth }); + const preview = useCallback( + (_id: string, next: number[]) => { + setPreviewWidth( + resolveExplorerSidebarWidth({ requestedWidth: next[1] * containerWidth, containerWidth }), + ); + }, + [containerWidth], + ); + const commit = useCallback( + (_id: string, next: number[]) => { + setPreviewWidth(null); + onResize( + resolveExplorerSidebarWidth({ requestedWidth: next[1] * containerWidth, containerWidth }), + ); + }, + [containerWidth, onResize], + ); + return { + frameStyle: inlineUnistylesStyle({ width: overlay ? undefined : requestedWidth }), + panelStyle: undefined, + resizeHandle: ( + + + + ), + }; +} +const styles = StyleSheet.create({ + resize: { position: "absolute", left: 0, top: 0, bottom: 0, zIndex: 2, flexDirection: "row" }, +}); diff --git a/packages/app/src/screens/workspace/sheet-tabs.tsx b/packages/app/src/screens/workspace/sheet-tabs.tsx index 2b4e927a13..de13097503 100644 --- a/packages/app/src/screens/workspace/sheet-tabs.tsx +++ b/packages/app/src/screens/workspace/sheet-tabs.tsx @@ -1,5 +1,5 @@ import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet"; -import { useIsFocused } from "@react-navigation/native"; +import { useIsFocused } from "expo-router/react-navigation"; import { type ComponentType, type FC, diff --git a/packages/app/src/screens/workspace/visible-agent-ids.test.ts b/packages/app/src/screens/workspace/visible-agent-ids.test.ts index d7b6f33a72..1378d6cc20 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.test.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.test.ts @@ -132,3 +132,36 @@ test("the agent shown in the workspace sheet is viewed alongside the pane under selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: false, focusedPaneOnly: true }), ).toEqual([]); }); + +test("single-pane tablet includes its visible Explorer agent without including other splits", () => { + const layout: WorkspaceLayout = { + focusedPaneId: "main", + root: { + kind: "group", + group: { + id: "root", + direction: "horizontal", + sizes: [0.5, 0.25, 0.25], + children: [ + { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } }, + { kind: "pane", pane: { id: "explorer", tabIds: ["b"], focusedTabId: "b" } }, + { kind: "pane", pane: { id: "other", tabIds: ["c"], focusedTabId: "c" } }, + ], + }, + }, + }; + const tabs: WorkspaceTab[] = ["a", "b", "c"].map((id) => ({ + tabId: id, + createdAt: 1, + target: { kind: "agent", agentId: id }, + })); + expect( + selectVisibleAgentIds({ + layout, + tabs, + routeFocused: true, + focusedPaneOnly: true, + explorerPaneId: "explorer", + }), + ).toEqual(["a", "b"]); +}); diff --git a/packages/app/src/screens/workspace/visible-agent-ids.ts b/packages/app/src/screens/workspace/visible-agent-ids.ts index 324d4121c3..8643e84940 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.ts @@ -10,12 +10,18 @@ export function selectVisibleAgentIds(input: { sheetTab?: WorkspaceTab | null; routeFocused: boolean; focusedPaneOnly: boolean; + explorerPaneId?: string | null; + explorerVisible?: boolean; }): string[] { if (!input.routeFocused || !input.layout) { return []; } const panes = input.focusedPaneOnly - ? collectAllPanes(input.layout.root).filter((pane) => pane.id === input.layout?.focusedPaneId) + ? collectAllPanes(input.layout.root).filter( + (pane) => + pane.id === input.layout?.focusedPaneId || + (input.explorerVisible !== false && pane.id === input.explorerPaneId), + ) : collectAllPanes(input.layout.root); const targets = panes.flatMap((pane) => { @@ -35,6 +41,8 @@ export function useVisibleAgentIds(input: { sheetTab: WorkspaceTab | null; routeFocused: boolean; focusedPaneOnly: boolean; + explorerPaneId?: string | null; + explorerVisible?: boolean; }): string[] { const nextAgentIds = selectVisibleAgentIds(input); const stableAgentIds = useRef([]); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index f1806ede34..4daeb96cb1 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -858,7 +858,6 @@ function TabChip({ testID={`workspace-tab-${testIdentity}`} triggerRef={dragHandleProps?.setActivatorNodeRef as unknown as undefined} style={tabChipStyle} - onPressIn={isWeb ? handleNavigateTab : undefined} onPress={handleNavigateTab} accessibilityRole="button" accessibilityLabel={accessibilityLabel} diff --git a/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx b/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx index 5e8c3eea78..55a36f9779 100644 --- a/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx +++ b/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx @@ -92,6 +92,6 @@ export function WorkspaceExplorerSidebarToggle({ owner, ...toggleProps }: DesktopWorkspaceExplorerToggleProps) { - if (owner !== "window") return null; + if (owner === "header") return null; return ; } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 5c3e1560e1..530c1a07d0 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,9 +1,15 @@ +import { useHostFeature } from "@/runtime/host-features"; import { AssistantFileLinkResolverProvider, createInlinePathOpener, type InlinePathTarget, } from "@/assistant-file-links"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; +import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; +import type { LayoutChangeEvent } from "react-native"; +import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar"; +import { ExplorerFrame } from "./explorer-sidebar/frame"; +import { WorkspaceTabDragScope } from "@/components/workspace-tab-drag-scope"; import { WorkspaceComposerEditingSessionsProvider, useComposerTabFullscreen, @@ -24,7 +30,7 @@ import { type ReactNode, } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import { useIsFocused } from "@react-navigation/native"; +import { useIsFocused } from "expo-router/react-navigation"; import { BackHandler, Text, View } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; import { useRouter, type Href } from "expo-router"; @@ -72,7 +78,6 @@ import { openWorkspaceTargetBeside, } from "@/workspace-tabs/open-beside"; import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"; -import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { traceInstant } from "@/performance/native-trace"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { @@ -214,7 +219,7 @@ import { } from "@/workspace/file-open"; import { RenderProfile } from "@/utils/render-profiler"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; -import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request"; +import { useWorkspacePullRequestDiscovery } from "@/panels/pull-request"; const NO_SHEETS: readonly SheetTab[] = []; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; @@ -223,6 +228,10 @@ const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; const EMPTY_PINNED_AGENT_IDS = new Set(); const EMPTY_SET = new Set(); +function getWorkspaceSetupStatus(workspace: WorkspaceDescriptor | null | undefined) { + return workspace?.setupStatus ?? null; +} + function getWorkspaceScripts( workspaceDescriptor: WorkspaceDescriptor | null | undefined, ): WorkspaceDescriptor["scripts"] { @@ -767,12 +776,14 @@ function WorkspaceContentProviders({ ); } -function WorkspacePanelProviders({ +function WorkspacePanelSurface({ launcher, children, tabIds, workspaceKey, + onLayout, }: { + onLayout: (event: LayoutChangeEvent) => void; workspaceKey: string; launcher: NewTabLauncher; children: ReactNode; @@ -780,7 +791,13 @@ function WorkspacePanelProviders({ }) { return ( - {children} + + + + {children} + + + ); } @@ -869,14 +886,6 @@ function paneLocalPlacement(paneId: string | null | undefined): WorkspaceTabPlac return paneId ? { mode: "pane", paneId } : FOCUSED_PANE_PLACEMENT; } -function canDetectPullRequest( - isRouteFocused: boolean, - isGitCheckout: boolean, - isCompact: boolean, -): boolean { - return isRouteFocused && isGitCheckout && !isCompact && supportsDesktopPaneSplits(); -} - interface WorkspaceTerminalTabActionsInput { persistenceKey: string | null; openWorkspaceTabFocused: ( @@ -1012,6 +1021,8 @@ function WorkspaceScreenContent({ const _insets = useSafeAreaInsets(); const toast = useToast(); const isMobile = useIsCompactFormFactor(); + const canRenderDesktopPaneSplits = supportsDesktopPaneSplits(); + const layoutMode = useWorkspaceLayoutStore((state) => state.layoutMode); const hasMacTrafficLights = useHasWindowChromeObstruction("top-left"); const explorerToggleOwner = resolveWorkspaceExplorerToggleOwner({ isMobile, @@ -1244,35 +1255,22 @@ function WorkspaceScreenContent({ workspace: workspaceDescriptor, checkoutState: workspaceHeaderCheckoutState, }); - const canDetectPullRequestTab = canDetectPullRequest(isRouteFocused, isGitCheckout, isMobile); - const hasPullRequest = useHasPullRequest({ - serverId: normalizedServerId, - cwd: workspaceDirectory, - enabled: canDetectPullRequestTab, - }); - usePullRequestAutoAdd({ - workspaceKey: persistenceKey, - hasPullRequest, - enabled: canDetectPullRequestTab, - }); - const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const activeExplorerCheckout = useMemo(() => { - if (!normalizedServerId || !workspaceDirectory) { - return null; - } - return { - serverId: normalizedServerId, - cwd: workspaceDirectory, - isGit: isGitCheckout, - }; - }, [isGitCheckout, normalizedServerId, workspaceDirectory]); - const isExplorerSidebarShowing = useIsExplorerSidebarOpen({ isCompact: isMobile, workspaceKey: persistenceKey, }); + const hasPullRequest = useWorkspacePullRequestDiscovery({ + workspaceKey: persistenceKey, + serverId: normalizedServerId, + cwd: workspaceDirectory, + workspaceActive: isRouteFocused, + isGit: isGitCheckout, + layoutMode, + explorerOpen: isExplorerSidebarShowing, + }); + const explorerSidebarToggleAccessibilityState = useMemo( () => ({ expanded: isExplorerSidebarShowing }), [isExplorerSidebarShowing], @@ -1313,12 +1311,13 @@ function WorkspaceScreenContent({ }); const lastMainPaneId = lastMainPaneRef.current.paneId; const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated(); - const workspaceSetupSnapshot = useWorkspaceSetupStore((state) => - persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null, - ); - const ensureWorkspaceSetupStatus = useWorkspaceSetupStore((state) => state.ensureSetupStatus); + const workspaceSetupStatus = getWorkspaceSetupStatus(workspaceDescriptor); const claimFailedSetupSurface = useWorkspaceSetupStore((state) => state.claimFailedSetupSurface); - const showWorkspaceSetup = shouldShowWorkspaceSetup(workspaceSetupSnapshot); + const supportsSetupObservation = useHostFeature(normalizedServerId, "workspaceSetupObservation"); + const showWorkspaceSetup = shouldShowWorkspaceSetup( + workspaceSetupStatus, + supportsSetupObservation, + ); const uiTabs = useMemo( () => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS), [workspaceLayout], @@ -1341,7 +1340,6 @@ function WorkspaceScreenContent({ ); const openInSidePane = useSettings((settings) => settings.openInSidePane); const pullRequestOpenLocation = useSettings((settings) => settings.pullRequestOpenLocation); - const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const sheets = useWorkspaceLayoutStore((state) => @@ -1363,9 +1361,8 @@ function WorkspaceScreenContent({ toggleExplorerSidebar({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: activeExplorerCheckout, }); - }, [activeExplorerCheckout, isMobile, persistenceKey]); + }, [isMobile, persistenceKey]); const paneFocusSuppressedRef = useRef(false); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); @@ -1426,6 +1423,8 @@ function WorkspaceScreenContent({ sheetTab, routeFocused: isRouteFocused, focusedPaneOnly: syncFocusedPaneOnly, + explorerPaneId: explorerSidebarPaneId, + explorerVisible: isExplorerSidebarShowing, }); useEffect(() => { for (const agentId of visibleAgentIds) { @@ -1585,9 +1584,9 @@ function WorkspaceScreenContent({ if (!tabId || !persistenceKey) { return; } - focusWorkspaceTab(persistenceKey, tabId); + revealWorkspaceTab(persistenceKey, tabId); }, - [focusWorkspaceTab, persistenceKey], + [persistenceKey], ); const selectTabInPane = useCallback( (paneId: string, tabId: string) => { @@ -1624,7 +1623,7 @@ function WorkspaceScreenContent({ if (!persistenceKey) { return; } - if (!shouldSeedWorkspaceSetupTab(workspaceSetupSnapshot)) { + if (!shouldSeedWorkspaceSetupTab(workspaceSetupStatus)) { return; } @@ -1639,6 +1638,7 @@ function WorkspaceScreenContent({ !claimFailedSetupSurface({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, + status: workspaceSetupStatus, }) ) { return; @@ -1659,24 +1659,7 @@ function WorkspaceScreenContent({ normalizedServerId, openWorkspaceTabInBackground, persistenceKey, - workspaceSetupSnapshot, - ]); - - useEffect(() => { - if (!isRouteFocused || !client || !normalizedServerId || !normalizedWorkspaceId) { - return; - } - ensureWorkspaceSetupStatus({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - client, - }); - }, [ - client, - ensureWorkspaceSetupStatus, - isRouteFocused, - normalizedServerId, - normalizedWorkspaceId, + workspaceSetupStatus, ]); const handleOpenFileFromChat = useCallback( @@ -1717,9 +1700,6 @@ function WorkspaceScreenContent({ if (!location) { return; } - if (isMobile) { - showMobileAgent(); - } if (!persistenceKey) { return; } @@ -1733,17 +1713,9 @@ function WorkspaceScreenContent({ }); if (tabId) { requestFileNavigation(tabId); - navigateToTabId(tabId); } }, - [ - isMobile, - navigateToTabId, - openInSidePane, - persistenceKey, - requestFileNavigation, - showMobileAgent, - ], + [openInSidePane, persistenceKey, requestFileNavigation], ); const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({ @@ -1770,7 +1742,6 @@ function WorkspaceScreenContent({ }); if (tabId) { requestFileNavigation(tabId); - navigateToTabId(tabId); } return; } @@ -2485,7 +2456,6 @@ function WorkspaceScreenContent({ openExplorerSidebarView({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: activeExplorerCheckout, view: action.target === "files" ? "files" : "changes", }); return true; @@ -2494,7 +2464,6 @@ function WorkspaceScreenContent({ openWorkspacePullRequest({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: activeExplorerCheckout, destination: pullRequestOpenLocation, }); return true; @@ -2518,7 +2487,6 @@ function WorkspaceScreenContent({ return true; }, [ - activeExplorerCheckout, focusedPaneTabState.pane?.id, isMobile, openWorkspaceTabFocused, @@ -2659,7 +2627,6 @@ function WorkspaceScreenContent({ openExplorerSidebarView({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: activeExplorerCheckout, view: "changes", }); } @@ -2669,7 +2636,6 @@ function WorkspaceScreenContent({ openExplorerSidebarView({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: activeExplorerCheckout, view: "files", }); } @@ -2680,7 +2646,6 @@ function WorkspaceScreenContent({ }, [ focusedPaneTabState.pane?.id, - activeExplorerCheckout, handleCreateBrowserTab, handleCreateDraftTab, isGitCheckout, @@ -2957,7 +2922,6 @@ function WorkspaceScreenContent({ }), [activeFileLineEnd, activeFileLineStart, activeFilePath], ); - const canRenderDesktopPaneSplits = supportsDesktopPaneSplits(); const shouldRenderDesktopPaneFallback = useMemo( () => !isMobile && !canRenderDesktopPaneSplits, [isMobile, canRenderDesktopPaneSplits], @@ -2981,11 +2945,7 @@ function WorkspaceScreenContent({ normalizedWorkspaceId, host: input.host ?? - (canRenderDesktopPaneSplits && - input.paneId !== null && - input.paneId === explorerSidebarPaneId - ? "explorer" - : "main"), + (input.paneId !== null && input.paneId === explorerSidebarPaneId ? "explorer" : "main"), fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0, onOpenTab: (target) => { if (!persistenceKey) { @@ -3014,7 +2974,6 @@ function WorkspaceScreenContent({ preferences: openInSidePane, }); if (tabId && target.kind === "file") requestFileNavigation(tabId); - if (tabId) navigateToTabId(tabId); }, onOpenTargetToSide: canRenderDesktopPaneSplits && @@ -3028,7 +2987,6 @@ function WorkspaceScreenContent({ parentTabId: input.tab.tabId, }); if (tabId && target.kind === "file") requestFileNavigation(tabId); - if (tabId) navigateToTabId(tabId); } : undefined, onCloseCurrentTab: () => { @@ -3512,7 +3470,6 @@ function WorkspaceScreenContent({ - - - {(topOverlayInset) => ( - - {content} - - )} - - - - + + + {(topOverlayInset) => ( + + {content} + + )} + + + ) : ( {rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()} @@ -3656,17 +3605,91 @@ function WorkspaceScreenContent({ ) : null} - - {desktopContent} - + {desktopContent} ); + const [workspaceViewportWidth, setWorkspaceViewportWidth] = useState(0); + const measureWorkspaceWidth = useCallback( + (event: LayoutChangeEvent) => setWorkspaceViewportWidth(event.nativeEvent.layout.width), + [], + ); + const sharedExplorer = useMemo(() => { + if (!workspaceLayout || !explorerSidebarPaneId || !persistenceKey) return null; + const explorerPane = findPaneById(workspaceLayout.root, explorerSidebarPaneId); + if (!explorerPane) return null; + const dock = ( + + ); + return ( + + {dock} + + ); + }, [ + workspaceLayout, + explorerSidebarPaneId, + persistenceKey, + isMobile, + uiTabs, + normalizedServerId, + normalizedWorkspaceId, + isRouteFocused, + isExplorerSidebarShowing, + closingTabIds, + hoveredCloseTabKey, + selectTabInPane, + handleCloseTabById, + handleCreateNewTab, + handleCopyResumeCommand, + handleCopyAgentId, + handleCopyTerminalId, + handleCopyFilePath, + handleReloadAgent, + handleRenameTab, + handleCloseTabsToLeftInPane, + handleCloseTabsToRightInPane, + handleCloseOtherTabsInPane, + toggleFocusMode, + handleReorderTabsInPane, + buildDesktopPaneContentModel, + renderExplorerSidebarHeaderAction, + workspaceViewportWidth, + desktopFocusModeEnabled, + ]); + const { revealDirectory } = useFileExplorerActions({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -3680,7 +3703,6 @@ function WorkspaceScreenContent({ openExplorerSidebarView({ isCompact: isMobile, workspaceKey: persistenceKey, - checkout: activeExplorerCheckout, view: "files", }); }); @@ -3713,12 +3735,30 @@ function WorkspaceScreenContent({ workspaceId={normalizedWorkspaceId} isRouteFocused={isRouteFocused} /> - + - {workspaceCenterColumn} + + {workspaceCenterColumn} + {sharedExplorer} + - + ({ backgroundColor: theme.colors.surfaceWorkspace, }, threePaneRow: { + // Keep the Explorer overlay below the workspace sheet host on web as well as native. + zIndex: 0, flex: 1, minHeight: 0, flexDirection: "row", @@ -3961,7 +4003,7 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", }, mobileMountedTabSlot: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, }, contentPlaceholder: { flex: 1, diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index b2b226e645..14e7b89b31 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -1,3 +1,7 @@ +import { + WorkspaceHeaderExplorerToggle, + WorkspaceExplorerSidebarToggle, +} from "./workspace-explorer-toggle"; import { describe, expect, it } from "vitest"; import { computeWorkspaceTabLayout, @@ -144,3 +148,25 @@ describe("retainWorkspaceTabMeasuredWidth", () => { expect(retainWorkspaceTabMeasuredWidth(720, 640)).toBe(640); }); }); + +describe("Explorer toggle ownership", () => { + const toggleProps = { + onPress: () => {}, + label: "Close Explorer sidebar", + tooltipLabel: "Toggle Explorer sidebar", + tooltipKeys: [], + accessibilityState: { expanded: true }, + }; + + it("keeps only the header toggle when window controls do not obstruct it", () => { + expect(WorkspaceHeaderExplorerToggle({ ...toggleProps, owner: "header" })).not.toBeNull(); + expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner: "header" })).toBeNull(); + }); + + it("keeps the dock toggle for window-owned and compact overlay chrome", () => { + for (const owner of ["window", "mobile"] as const) { + expect(WorkspaceHeaderExplorerToggle({ ...toggleProps, owner })).toBeNull(); + expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner })).not.toBeNull(); + } + }); +}); diff --git a/packages/app/src/storage/validated-persist-storage.test.ts b/packages/app/src/storage/validated-persist-storage.test.ts index 7255086e37..4f102761e9 100644 --- a/packages/app/src/storage/validated-persist-storage.test.ts +++ b/packages/app/src/storage/validated-persist-storage.test.ts @@ -63,3 +63,66 @@ describe("createValidatedPersistStorage", () => { expect(backing.values.has("settings")).toBe(false); }); }); + +it("skips repeated immutable states only when requested", async () => { + let writes = 0; + const backing: StateStorage = { + getItem: () => null, + setItem: () => { + writes += 1; + }, + removeItem: () => {}, + }; + const value = { state: { enabled: true }, version: 1 }; + const regular = createValidatedPersistStorage(backing, StateSchema); + await regular.setItem("regular", value); + await regular.setItem("regular", value); + expect(writes).toBe(2); + + const deduplicated = createValidatedPersistStorage(backing, StateSchema, { skipUnchanged: true }); + await deduplicated.setItem("first", value); + await deduplicated.setItem("first", { ...value }); + expect(writes).toBe(3); + await deduplicated.setItem("second", value); + expect(writes).toBe(4); + await deduplicated.setItem("first", { ...value, version: 2 }); + expect(writes).toBe(5); +}); + +it("retries an unchanged state after a failed save", async () => { + let attempts = 0; + const backing = new MemoryStorage(); + const storage = createValidatedPersistStorage( + { + getItem: (name) => backing.getItem(name), + setItem: (name, value) => { + attempts += 1; + if (attempts === 1) return Promise.reject(new Error("storage unavailable")); + backing.setItem(name, value); + }, + removeItem: (name) => backing.removeItem(name), + }, + StateSchema, + { skipUnchanged: true }, + ); + const value = { state: { enabled: true }, version: 1 }; + await expect(storage.setItem("layout", value)).rejects.toThrow("storage unavailable"); + await storage.setItem("layout", value); + expect(attempts).toBe(2); + expect(backing.getItem("layout")).toBe(JSON.stringify(value)); +}); + +it("saves the same state again after removal or rehydration clears invalid storage", async () => { + const backing = new MemoryStorage(); + const storage = createValidatedPersistStorage(backing, StateSchema, { skipUnchanged: true }); + const value = { state: { enabled: true }, version: 1 }; + await storage.setItem("layout", value); + await storage.removeItem("layout"); + await storage.setItem("layout", value); + expect(backing.getItem("layout")).toBe(JSON.stringify(value)); + + backing.values.set("layout", "{"); + await expect(storage.getItem("layout")).resolves.toBeNull(); + await storage.setItem("layout", value); + expect(backing.getItem("layout")).toBe(JSON.stringify(value)); +}); diff --git a/packages/app/src/storage/validated-persist-storage.ts b/packages/app/src/storage/validated-persist-storage.ts index 07459f8642..e9a438daf9 100644 --- a/packages/app/src/storage/validated-persist-storage.ts +++ b/packages/app/src/storage/validated-persist-storage.ts @@ -1,17 +1,22 @@ import { z } from "zod"; -import type { PersistStorage, StateStorage } from "zustand/middleware"; +import type { PersistStorage, StateStorage, StorageValue } from "zustand/middleware"; export function createValidatedPersistStorage( backingStorage: StateStorage, stateSchema: z.ZodType, + // Opt in only when callers preserve state references until values change. + options: { skipUnchanged?: boolean } = {}, ): PersistStorage { const envelopeSchema = z.strictObject({ state: stateSchema, version: z.number().int().nonnegative().optional(), }); + const accepted = new Map>(); + return { getItem: async (name) => { + accepted.delete(name); const raw = await backingStorage.getItem(name); if (raw === null) return null; @@ -31,13 +36,31 @@ export function createValidatedPersistStorage( return result.data; }, setItem: async (name, value) => { + const previous = accepted.get(name); + if ( + options.skipUnchanged && + previous && + previous.state === value.state && + previous.version === value.version + ) + return; const result = envelopeSchema.safeParse(value); if (!result.success) { + accepted.delete(name); await backingStorage.removeItem(name); return; } - await backingStorage.setItem(name, JSON.stringify(result.data)); + if (options.skipUnchanged) accepted.set(name, value); + try { + await backingStorage.setItem(name, JSON.stringify(result.data)); + } catch (error) { + if (accepted.get(name) === value) accepted.delete(name); + throw error; + } + }, + removeItem: (name) => { + accepted.delete(name); + return backingStorage.removeItem(name); }, - removeItem: (name) => backingStorage.removeItem(name), }; } diff --git a/packages/app/src/stores/explorer-checkout-context.ts b/packages/app/src/stores/explorer-checkout-context.ts deleted file mode 100644 index 926c02d4e0..0000000000 --- a/packages/app/src/stores/explorer-checkout-context.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ExplorerCheckoutContext { - serverId: string; - cwd: string; - isGit: boolean; -} diff --git a/packages/app/src/stores/explorer-tab-memory.ts b/packages/app/src/stores/explorer-tab-memory.ts deleted file mode 100644 index 3e26c0e947..0000000000 --- a/packages/app/src/stores/explorer-tab-memory.ts +++ /dev/null @@ -1,34 +0,0 @@ -export type ExplorerTab = "changes" | "files" | "pr"; - -export function isExplorerTab(value: unknown): value is ExplorerTab { - return value === "changes" || value === "files" || value === "pr"; -} - -export function buildExplorerCheckoutKey(serverId: string, cwd: string): string | null { - const trimmedServerId = serverId.trim(); - const trimmedCwd = cwd.trim(); - if (!trimmedServerId || !trimmedCwd) { - return null; - } - return `${trimmedServerId}::${trimmedCwd}`; -} - -export function coerceExplorerTabForCheckout(tab: ExplorerTab, isGit: boolean): ExplorerTab { - if (!isGit && tab === "changes") { - return "files"; - } - return tab; -} - -export function resolveExplorerTabForCheckout(params: { - serverId: string; - cwd: string; - isGit: boolean; - explorerTabByCheckout: Record; -}): ExplorerTab { - const key = buildExplorerCheckoutKey(params.serverId, params.cwd); - const stored = key ? params.explorerTabByCheckout[key] : null; - const defaultTab: ExplorerTab = params.isGit ? "changes" : "files"; - const nextTab = stored && isExplorerTab(stored) ? stored : defaultTab; - return coerceExplorerTabForCheckout(nextTab, params.isGit); -} diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index 0fa86399d9..8af964f181 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -2,15 +2,6 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { - buildExplorerCheckoutKey, - coerceExplorerTabForCheckout, - resolveExplorerTabForCheckout, - type ExplorerTab, -} from "../explorer-tab-memory"; -import { type ExplorerCheckoutContext } from "../explorer-checkout-context"; -import { - buildOpenFileExplorerPatch, - buildToggleFileExplorerPatch, clampTreeRailWidth, clampSidebarWidth, DEFAULT_TREE_RAIL_WIDTH, @@ -32,8 +23,6 @@ import { } from "./state"; import { isWeb } from "@/constants/platform"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; -export type { ExplorerTab } from "../explorer-tab-memory"; -export type { ExplorerCheckoutContext } from "../explorer-checkout-context"; export type { DesktopSidebarState, MobilePanelView, @@ -63,8 +52,6 @@ export interface PanelState { desktop: DesktopSidebarState; // File explorer settings (shared between mobile/desktop) - explorerTab: ExplorerTab; - explorerTabByCheckout: Record; expandedPathsByWorkspace: Record; // Changes-view folder tree. Inverted semantics vs the fields above: // this stores COLLAPSED directory paths (empty = all folders expanded), keyed @@ -85,6 +72,7 @@ export interface PanelState { exitFocusMode: () => void; showMobileAgent: () => void; showMobileAgentList: () => void; + showMobileFileExplorer: () => void; toggleMobileAgentList: () => void; openDesktopAgentList: () => void; closeDesktopAgentList: () => void; @@ -92,16 +80,11 @@ export interface PanelState { openAgentListForLayout: (input: PanelLayoutInput) => void; closeAgentListForLayout: (input: PanelLayoutInput) => void; toggleAgentListForLayout: (input: PanelLayoutInput) => void; - openCompactFileExplorer: (checkout: ExplorerCheckoutContext) => void; - toggleCompactFileExplorer: (checkout: ExplorerCheckoutContext) => void; // File explorer settings actions - setExplorerTab: (tab: ExplorerTab) => void; - setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void; setExpandedPathsForWorkspace: (workspaceKey: string, paths: ExpandedPathsUpdate) => void; setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void; setCollapsedFilePathsForWorkspace: (workspaceKey: string, paths: string[]) => void; - activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void; setSidebarWidth: (width: number) => void; setExplorerSortOption: (option: SortOption) => void; toggleExplorerShowHiddenFiles: () => void; @@ -132,8 +115,6 @@ export const usePanelStore = create()( }, // File explorer defaults - explorerTab: "changes", - explorerTabByCheckout: {}, expandedPathsByWorkspace: {}, diffCollapsedFoldersByWorkspace: {}, collapsedFilePathsByWorkspace: {}, @@ -158,6 +139,8 @@ export const usePanelStore = create()( showMobileAgent: () => set((state) => setMobilePanelTargetPatch(state, "agent")), showMobileAgentList: () => set((state) => setMobilePanelTargetPatch(state, "agent-list")), + showMobileFileExplorer: () => + set((state) => setMobilePanelTargetPatch(state, "file-explorer")), toggleMobileAgentList: () => set((state) => @@ -221,29 +204,6 @@ export const usePanelStore = create()( }; }), - openCompactFileExplorer: (checkout) => - set((state) => buildOpenFileExplorerPatch(state, checkout)), - - toggleCompactFileExplorer: (checkout) => - set((state) => buildToggleFileExplorerPatch(state, checkout)), - - setExplorerTab: (tab) => set({ explorerTab: tab }), - setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) => - set((state) => { - const resolvedTab = coerceExplorerTabForCheckout(tab, isGit); - const key = buildExplorerCheckoutKey(serverId, cwd); - const nextState: Partial = { explorerTab: resolvedTab }; - if (key) { - const current = state.explorerTabByCheckout[key]; - if (current !== resolvedTab) { - nextState.explorerTabByCheckout = { - ...state.explorerTabByCheckout, - [key]: resolvedTab, - }; - } - } - return nextState; - }), setExpandedPathsForWorkspace: (workspaceKey, paths) => set((state) => { const currentPaths = state.expandedPathsByWorkspace[workspaceKey] ?? ["."]; @@ -269,15 +229,6 @@ export const usePanelStore = create()( [workspaceKey]: paths, }, })), - activateExplorerTabForCheckout: (checkout) => - set((state) => ({ - explorerTab: resolveExplorerTabForCheckout({ - serverId: checkout.serverId, - cwd: checkout.cwd, - isGit: checkout.isGit, - explorerTabByCheckout: state.explorerTabByCheckout, - }), - })), setSidebarWidth: (width) => set({ sidebarWidth: clampSidebarWidth(width) }), setExplorerSortOption: (option) => set({ explorerSortOption: option }), toggleExplorerShowHiddenFiles: () => @@ -287,13 +238,11 @@ export const usePanelStore = create()( }), { name: "panel-state", - version: 16, + version: 17, storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema), migrate: (persistedState, version) => migratePanelState(persistedState, version), partialize: (state) => ({ desktop: state.desktop, - explorerTab: state.explorerTab, - explorerTabByCheckout: state.explorerTabByCheckout, expandedPathsByWorkspace: state.expandedPathsByWorkspace, diffCollapsedFoldersByWorkspace: state.diffCollapsedFoldersByWorkspace, collapsedFilePathsByWorkspace: state.collapsedFilePathsByWorkspace, diff --git a/packages/app/src/stores/panel-store/state.test.ts b/packages/app/src/stores/panel-store/state.test.ts index 19c8d7d17a..ad52b8a8c4 100644 --- a/packages/app/src/stores/panel-store/state.test.ts +++ b/packages/app/src/stores/panel-store/state.test.ts @@ -1,12 +1,5 @@ import { describe, expect, it } from "vitest"; import { - buildExplorerCheckoutKey, - resolveExplorerTabForCheckout, - type ExplorerTab, -} from "@/stores/explorer-tab-memory"; -import { - buildOpenFileExplorerPatch, - buildToggleFileExplorerPatch, DEFAULT_TREE_RAIL_WIDTH, migratePanelState, selectIsAgentListOpen, @@ -22,81 +15,10 @@ function makePanelState(overrides: Partial = {}): PanelCoreState agentListOpen: false, focusModeEnabled: false, }, - explorerTab: "changes", - explorerTabByCheckout: {}, ...overrides, }; } -describe("panel-store explorer tab resolution", () => { - const serverId = "server-1"; - const cwd = "/tmp/repo"; - - it("defaults to changes for git checkouts", () => { - expect( - resolveExplorerTabForCheckout({ - serverId, - cwd, - isGit: true, - explorerTabByCheckout: {}, - }), - ).toBe("changes"); - }); - - it("defaults to files for non-git checkouts", () => { - expect( - resolveExplorerTabForCheckout({ - serverId, - cwd, - isGit: false, - explorerTabByCheckout: {}, - }), - ).toBe("files"); - }); - - it("restores a stored files tab for git checkouts", () => { - const key = buildExplorerCheckoutKey(serverId, cwd)!; - expect( - resolveExplorerTabForCheckout({ - serverId, - cwd, - isGit: true, - explorerTabByCheckout: { - [key]: "files", - }, - }), - ).toBe("files"); - }); - - it("falls back to default when stored tab is invalid", () => { - const key = buildExplorerCheckoutKey(serverId, cwd)!; - expect( - resolveExplorerTabForCheckout({ - serverId, - cwd, - isGit: true, - explorerTabByCheckout: { - [key]: "terminals" as unknown as ExplorerTab, - }, - }), - ).toBe("changes"); - }); - - it("coerces stored changes to files for non-git checkouts", () => { - const key = buildExplorerCheckoutKey(serverId, cwd)!; - expect( - resolveExplorerTabForCheckout({ - serverId, - cwd, - isGit: false, - explorerTabByCheckout: { - [key]: "changes", - }, - }), - ).toBe("files"); - }); -}); - describe("panel-store migration", () => { it("defaults hidden-file visibility to showing hidden files", () => { const state = migratePanelState({}, 10); @@ -191,57 +113,12 @@ describe("panel-store visibility selectors", () => { }); }); -describe("panel-store compact file explorer actions", () => { - it("opens the compact explorer and resolves the tab from the explicit checkout", () => { - const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; - const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; - const state = makePanelState({ - explorerTab: "changes", - explorerTabByCheckout: { [key]: "files" }, - }); - - const patch = buildOpenFileExplorerPatch(state, checkout); - - expect(patch.mobilePanel).toEqual({ target: "file-explorer", revision: 1 }); - expect(patch.explorerTab).toBe("files"); - }); - - it("toggles the explorer closed without changing the active tab", () => { - const state = makePanelState({ - mobilePanel: { target: "file-explorer", revision: 1 }, - explorerTab: "files", - }); - - const patch = buildToggleFileExplorerPatch(state, { - serverId: "server-1", - cwd: "/tmp/repo", - isGit: true, - }); - - expect(patch).toEqual({ mobilePanel: { target: "agent", revision: 2 } }); - }); - - it("coerces changes to files for a non-git checkout", () => { - const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: false }; - const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; - const state = makePanelState({ - explorerTab: "changes", - explorerTabByCheckout: { [key]: "changes" }, - }); - - expect(buildOpenFileExplorerPatch(state, checkout).explorerTab).toBe("files"); - }); - - it("opens with the default files tab for an explicit non-git checkout with no stored tab", () => { - const state = makePanelState({ explorerTab: "changes", explorerTabByCheckout: {} }); - - const patch = buildOpenFileExplorerPatch(state, { - serverId: "server-1", - cwd: "/tmp/non-git", - isGit: false, - }); - - expect(patch.mobilePanel).toEqual({ target: "file-explorer", revision: 1 }); - expect(patch.explorerTab).toBe("files"); - }); +it("discards obsolete checkout selection while preserving other panel preferences", () => { + const state = migratePanelState( + { explorerTab: "files", explorerTabByCheckout: { checkout: "changes" }, sidebarWidth: 400 }, + 16, + ); + expect(state).not.toHaveProperty("explorerTab"); + expect(state).not.toHaveProperty("explorerTabByCheckout"); + expect(state.sidebarWidth).toBe(400); }); diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts index c9b68d2584..cbbead4161 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -1,10 +1,3 @@ -import { - buildExplorerCheckoutKey, - isExplorerTab, - resolveExplorerTabForCheckout, - type ExplorerTab, -} from "../explorer-tab-memory"; -import { type ExplorerCheckoutContext } from "../explorer-checkout-context"; import { z } from "zod"; export type MobilePanelView = "agent" | "agent-list" | "file-explorer"; @@ -36,8 +29,6 @@ export interface PanelLayoutInput { export interface PanelCoreState { mobilePanel: MobilePanelSelection; desktop: DesktopSidebarState; - explorerTab: ExplorerTab; - explorerTabByCheckout: Record; } function clampNumber(value: number, min: number, max: number): number { @@ -77,45 +68,6 @@ export function setMobilePanelTarget( return { target, revision: selection.revision + 1 }; } -function resolveExplorerTabFromCheckout( - state: PanelCoreState, - checkout: ExplorerCheckoutContext, -): ExplorerTab { - return resolveExplorerTabForCheckout({ - serverId: checkout.serverId, - cwd: checkout.cwd, - isGit: checkout.isGit, - explorerTabByCheckout: state.explorerTabByCheckout, - }); -} - -export interface OpenFileExplorerPatch { - mobilePanel: MobilePanelSelection; - explorerTab: ExplorerTab; -} - -export function buildOpenFileExplorerPatch( - state: PanelCoreState, - checkout: ExplorerCheckoutContext, -): OpenFileExplorerPatch { - return { - mobilePanel: setMobilePanelTarget(state.mobilePanel, "file-explorer"), - explorerTab: resolveExplorerTabFromCheckout(state, checkout), - }; -} - -export type ToggleFileExplorerPatch = OpenFileExplorerPatch | { mobilePanel: MobilePanelSelection }; - -export function buildToggleFileExplorerPatch( - state: PanelCoreState, - checkout: ExplorerCheckoutContext, -): ToggleFileExplorerPatch { - if (!selectIsCompactFileExplorerOpen(state)) { - return buildOpenFileExplorerPatch(state, checkout); - } - return { mobilePanel: setMobilePanelTarget(state.mobilePanel, "agent") }; -} - const ExplorerTabSchema = z.enum(["changes", "files", "pr"]); const DesktopSidebarStorageSchema = z.strictObject({ agentListOpen: z.boolean().optional(), @@ -156,26 +108,6 @@ export const PanelPersistedStateSchema = z.strictObject({ type MigratablePanelState = z.infer; -function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: number): void { - if ( - version < 4 || - typeof state.explorerTabByCheckout !== "object" || - !state.explorerTabByCheckout - ) { - state.explorerTabByCheckout = {}; - return; - } - const entries = Object.entries(state.explorerTabByCheckout); - const next: Record = {}; - for (const [key, value] of entries) { - if (!isExplorerTab(value)) { - continue; - } - next[key] = value; - } - state.explorerTabByCheckout = next; -} - function migratePanelDesktopFocusMode(state: MigratablePanelState): void { const desktop = state.desktop; if (!desktop) { @@ -216,10 +148,8 @@ export function migratePanelState(persistedState: unknown, version: number): Mig if (state.desktop) { delete state.desktop.fileExplorerOpen; } - if (!isExplorerTab(state.explorerTab)) { - state.explorerTab = "changes"; - } - migratePanelExplorerTabByCheckout(state, version); + delete state.explorerTab; + delete state.explorerTabByCheckout; if (version < 8) { migratePanelDesktopFocusMode(state); } @@ -263,6 +193,3 @@ export function migratePanelState(persistedState: unknown, version: number): Mig return state; } - -export { buildExplorerCheckoutKey, resolveExplorerTabForCheckout }; -export type { ExplorerTab, ExplorerCheckoutContext }; diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 9b70d90486..f7c54e07b2 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -111,6 +111,7 @@ export interface WorkspaceDescriptor { projectRootPath: string; workspaceDirectory: string; worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"]; + setupStatus?: WorkspaceDescriptorPayload["setupStatus"]; projectKind: WorkspaceDescriptorPayload["projectKind"]; workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; background?: boolean; @@ -149,6 +150,8 @@ export function normalizeWorkspaceDescriptor( // usable directory" (older daemons may omit it; the wire field is optional). workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "", worktreeSlug: payload.worktreeSlug, + // COMPAT(workspaceSetupObservation): added in v0.12.0, old hosts omit metadata; remove after 2027-04-19. + setupStatus: payload.setupStatus ?? null, projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, background: payload.background ?? false, diff --git a/packages/app/src/stores/workspace-layout-persistence.test.ts b/packages/app/src/stores/workspace-layout-persistence.test.ts new file mode 100644 index 0000000000..d386a40298 --- /dev/null +++ b/packages/app/src/stores/workspace-layout-persistence.test.ts @@ -0,0 +1,54 @@ +import { indexedDB, IDBKeyRange } from "fake-indexeddb"; +import { expect, it } from "vitest"; +import type { StateStorage } from "zustand/middleware"; +import { createWorkspaceLayoutStore } from "./workspace-layout-store"; + +Object.assign(globalThis, { indexedDB, IDBKeyRange }); + +it("skips unchanged reconciliations and immediately saves real layout changes", async () => { + const values = new Map(); + let writes = 0; + const storage: StateStorage = { + getItem: (name) => values.get(name) ?? null, + setItem: (name, value) => { + writes += 1; + values.set(name, value); + }, + removeItem: (name) => { + values.delete(name); + }, + }; + const store = createWorkspaceLayoutStore(undefined, storage); + await store.persist.rehydrate(); + const workspaceKey = "server:workspace"; + const openAgent = (agentId: string) => + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId }, + intent: "reveal", + }); + openAgent("agent-1"); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + const writesBefore = writes; + expect(writesBefore).toBe(1); + for (let index = 0; index < 50; index += 1) { + store.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["agent-1"], + autoOpenAgentIds: ["agent-1"], + knownTerminalIds: [], + standaloneTerminalIds: [], + }); + } + expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layout); + expect(writes).toBe(writesBefore); + + openAgent("agent-2"); + expect(writes).toBe(writesBefore + 1); + const restored = createWorkspaceLayoutStore(undefined, storage); + await restored.persist.rehydrate(); + expect(restored.getState().layoutByWorkspace[workspaceKey]).toEqual( + store.getState().layoutByWorkspace[workspaceKey], + ); +}); diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 5f170bed7e..a989dc0dd3 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -5096,6 +5096,21 @@ describe("compact workspace sheet", () => { expect(tabKinds(store)).toEqual(["terminal", "terminal"]); }); + it("honors an explicit Explorer pane destination on compact layouts", () => { + const store = createCompactStore(); + const paneId = store.getState().showExplorerSidebar(workspaceKey)!; + const tabId = store.getState().openTab({ + workspaceKey, + target: fileTarget, + intent: "new", + placement: { mode: "pane", paneId }, + }); + expect(topSheet(store)).toBeUndefined(); + expect( + findPaneById(store.getState().layoutByWorkspace[workspaceKey].root, paneId)?.focusedTabId, + ).toBe(tabId); + }); + it("keeps a chat shown in the sheet open for timeline sync until the sheet closes", () => { const store = createCompactStore(); seedAgents([ diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 2aea369df6..01c932605e 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -1,7 +1,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect, useState } from "react"; import { create } from "zustand"; -import { persist } from "zustand/middleware"; +import { persist, type StateStorage } from "zustand/middleware"; import type { z } from "zod"; import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; @@ -123,6 +123,10 @@ export interface SheetTab { parentTabId: string | null; } +export type WorkspaceTabDestination = + | { kind: "pane"; paneId: string; host: "main" | "explorer"; tabId: string } + | { kind: "sheet"; tabId: string }; + interface WorkspaceLayoutStore { /** Published once by the app shell; callers never pass a form factor to open a tab. */ layoutMode: WorkspaceLayoutMode; @@ -161,11 +165,13 @@ interface WorkspaceLayoutStore { resolvePlacement: () => Pick, ) => string | null; /** Reveals the Explorer sidebar without selecting a view. Returns its pane id. */ + ensureExplorerSidebar: (workspaceKey: string) => string | null; showExplorerSidebar: (workspaceKey: string) => string | null; hideExplorerSidebar: (workspaceKey: string) => void; /** Returns the ordinary right-side workspace pane, creating it when absent. */ ensureSidePane: (workspaceKey: string, options?: { focus: boolean }) => string | null; closeTab: (workspaceKey: string, tabId: string) => void; + getTabDestination: (workspaceKey: string, tabId: string) => WorkspaceTabDestination | null; focusTab: (workspaceKey: string, tabId: string) => void; selectTabInPane: (workspaceKey: string, paneId: string, tabId: string) => void; replaceTab: ( @@ -224,6 +230,7 @@ const MAX_TREE_DEPTH = 5; const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440; const WORKSPACE_LAYOUT_PERSIST_VERSION = 2; +type WorkspaceLayoutPersistedState = z.infer; function convertLegacyExplorerSidebarRatios( ratiosByWorkspace: Record, @@ -939,8 +946,57 @@ function createExplorerSidebarPane( return targetPaneId ? splitPaneEmpty(workspaceKey, { targetPaneId, position: "right" }) : null; } +const PERSISTED_SLICES = [ + "layoutByWorkspace", + "pinnedAgentIdsByWorkspace", + "splitSizesByWorkspace", + "explorerSidebarWidthByWorkspace", + "explorerSidebarPaneIdByWorkspace", + "sidePaneIdByWorkspace", + "pullRequestTabAutoOpenedByWorkspace", +] as const; + +// Zustand persist re-runs partialize after every set, including ones that return the state +// untouched. Reusing the previous projection while every persisted slice is the same +// reference lets validated storage skip unchanged writes before validation and serialization. +function createPersistedStateProjection() { + let previous: { source: WorkspaceLayoutStore; state: WorkspaceLayoutPersistedState } | null = + null; + return (source: WorkspaceLayoutStore): WorkspaceLayoutPersistedState => { + const last = previous; + if (last && PERSISTED_SLICES.every((slice) => last.source[slice] === source[slice])) { + return last.state; + } + const layoutByWorkspace: Record = {}; + for (const key in source.layoutByWorkspace) { + // Strip ephemeral (commit diff) tabs before persisting so they are + // dropped on reload rather than restored pointing at a rebased SHA. + layoutByWorkspace[key] = stripEphemeralTabsFromLayout( + normalizeLayout(source.layoutByWorkspace[key]), + ); + } + const state: WorkspaceLayoutPersistedState = { + layoutByWorkspace, + pinnedAgentIdsByWorkspace: Object.fromEntries( + Object.entries(source.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [ + key, + Array.from(agentIds), + ]), + ), + splitSizesByWorkspace: source.splitSizesByWorkspace, + explorerSidebarWidthByWorkspace: source.explorerSidebarWidthByWorkspace, + explorerPaneIdByWorkspace: source.explorerSidebarPaneIdByWorkspace, + sidePaneIdByWorkspace: source.sidePaneIdByWorkspace, + pullRequestTabAutoOpenedByWorkspace: source.pullRequestTabAutoOpenedByWorkspace, + }; + previous = { source, state }; + return state; + }; +} + export function createWorkspaceLayoutStore( ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds, + storage: StateStorage = AsyncStorage, ) { return create()( persist( @@ -986,12 +1042,15 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey, input.parentTabId, ); - const sheetPlacement = resolveSheetPlacement( - get(), - normalizedWorkspaceKey, - normalizedTarget, - input.intent, - ); + const sheetPlacement = + input.placement?.mode === "pane" + ? null + : resolveSheetPlacement( + get(), + normalizedWorkspaceKey, + normalizedTarget, + input.intent, + ); if (sheetPlacement) { const sheets = getSheets(get(), normalizedWorkspaceKey); const { keptIndex } = sheetPlacement; @@ -1051,8 +1110,18 @@ export function createWorkspaceLayoutStore( if (!result) { return null; } + const openedLayout = + get().layoutMode === "compact" && placement.explorerSidebarPaneId + ? (setPaneHiddenInLayout({ + layout: result.layout, + paneId: placement.explorerSidebarPaneId, + hidden: + findPaneById(placement.layout.root, placement.explorerSidebarPaneId)?.hidden === + true, + }) ?? result.layout) + : result.layout; const nextLayout = keepWorkspaceFocusOutOfExplorerSidebar( - result.layout, + openedLayout, placement.explorerSidebarPaneId, placement.layout.focusedPaneId, ); @@ -1125,7 +1194,7 @@ export function createWorkspaceLayoutStore( set(promoted.patch); return promoted.tabId; }, - showExplorerSidebar: (workspaceKey) => { + ensureExplorerSidebar: (workspaceKey) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { return null; @@ -1146,14 +1215,11 @@ export function createWorkspaceLayoutStore( state.layoutByWorkspace, normalizedWorkspaceKey, ); - const revealedLayout = - setPaneHiddenInLayout({ layout: currentLayout, paneId, hidden: false }) ?? - currentLayout; return { layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: keepWorkspaceFocusOutOfExplorerSidebar( - revealedLayout, + currentLayout, paneId, currentLayout.focusedPaneId, ), @@ -1166,6 +1232,18 @@ export function createWorkspaceLayoutStore( }); return paneId; }, + showExplorerSidebar: (workspaceKey) => { + const paneId = get().ensureExplorerSidebar(workspaceKey); + if (!paneId || get().layoutMode === "compact") return paneId; + set((state) => { + const layout = state.layoutByWorkspace[workspaceKey]; + const next = setPaneHiddenInLayout({ layout, paneId, hidden: false }); + return next + ? { layoutByWorkspace: { ...state.layoutByWorkspace, [workspaceKey]: next } } + : state; + }); + return paneId; + }, hideExplorerSidebar: (workspaceKey) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -1317,6 +1395,26 @@ export function createWorkspaceLayoutStore( }; }); }, + getTabDestination: (workspaceKey, tabId) => { + const state = get(); + if (state.sheetsByWorkspace[workspaceKey]?.some((sheet) => sheet.tab.tabId === tabId)) { + return { kind: "sheet", tabId }; + } + const layout = state.layoutByWorkspace[workspaceKey]; + if (!layout) return null; + const pane = findPaneContainingTab(layout.root, tabId); + if (!pane) return null; + const explorerId = resolveExplorerSidebarPaneId( + layout, + state.explorerSidebarPaneIdByWorkspace[workspaceKey], + ); + return { + kind: "pane", + paneId: pane.id, + host: pane.id === explorerId ? "explorer" : "main", + tabId, + }; + }, focusTab: (workspaceKey, tabId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedTabId = trimNonEmpty(tabId); @@ -1337,7 +1435,7 @@ export function createWorkspaceLayoutStore( setPaneHiddenInLayout({ layout, paneId: explorerSidebarPaneId, - hidden: false, + hidden: state.layoutMode === "compact" ? tabPane.hidden === true : false, }) ?? layout; nextLayout = selectTabInPaneInLayout({ @@ -2117,33 +2215,12 @@ export function createWorkspaceLayoutStore( { name: "workspace-layout-state", version: WORKSPACE_LAYOUT_PERSIST_VERSION, - storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema), + storage: createValidatedPersistStorage(storage, WorkspaceLayoutPersistedStateSchema, { + skipUnchanged: true, + }), migrate: (persistedState, version) => migrateWorkspaceLayoutPersistedState(persistedState, version, ids), - partialize: (state) => { - const layoutByWorkspace: Record = {}; - for (const key in state.layoutByWorkspace) { - // Strip ephemeral (commit diff) tabs before persisting so they are - // dropped on reload rather than restored pointing at a rebased SHA. - layoutByWorkspace[key] = stripEphemeralTabsFromLayout( - normalizeLayout(state.layoutByWorkspace[key]), - ); - } - return { - layoutByWorkspace, - pinnedAgentIdsByWorkspace: Object.fromEntries( - Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [ - key, - Array.from(agentIds), - ]), - ), - splitSizesByWorkspace: state.splitSizesByWorkspace, - explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace, - explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace, - sidePaneIdByWorkspace: state.sidePaneIdByWorkspace, - pullRequestTabAutoOpenedByWorkspace: state.pullRequestTabAutoOpenedByWorkspace, - }; - }, + partialize: createPersistedStateProjection(), merge: (persistedState, currentState) => { const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState); if (!result.success) { diff --git a/packages/app/src/stores/workspace-setup-store.test.ts b/packages/app/src/stores/workspace-setup-store.test.ts index 85c2696f57..17e012c153 100644 --- a/packages/app/src/stores/workspace-setup-store.test.ts +++ b/packages/app/src/stores/workspace-setup-store.test.ts @@ -3,129 +3,13 @@ import { shouldSeedWorkspaceSetupTab, shouldShowWorkspaceSetup, useWorkspaceSetupStore, - type WorkspaceSetupSnapshot, - type WorkspaceSetupStatusClient, - type WorkspaceSetupStatusResult, } from "./workspace-setup-store"; -function makeSnapshot( - status: WorkspaceSetupSnapshot["status"], - error: string | null = null, -): WorkspaceSetupSnapshot { - return { - workspaceId: "workspace-1", - status, - detail: { - type: "worktree_setup", - worktreePath: "/Users/test/project", - branchName: "main", - log: "", - commands: [], - }, - error, - updatedAt: Date.now(), - }; -} - -const DEFAULT_SNAPSHOT: WorkspaceSetupStatusResult["snapshot"] = { - status: "running", - detail: { - type: "worktree_setup", - worktreePath: "/Users/test/project", - branchName: "main", - log: "", - commands: [], - }, - error: null, -}; - -function setupResult( - workspaceId: string, - snapshot: WorkspaceSetupStatusResult["snapshot"] = DEFAULT_SNAPSHOT, -): WorkspaceSetupStatusResult { - return { requestId: "req-1", workspaceId, snapshot }; -} - -function makeClient(handler: (workspaceId: string) => Promise) { - const calls: string[] = []; - const client: WorkspaceSetupStatusClient = { - fetchWorkspaceSetupStatus: (workspaceId) => { - calls.push(workspaceId); - return handler(workspaceId); - }, - }; - return { client, calls }; -} - -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function flush() { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - -function storedSnapshots() { - return Object.values(useWorkspaceSetupStore.getState().snapshots); -} - -function resolveDefault(workspaceId: string): Promise { - return Promise.resolve(setupResult(workspaceId)); -} - -function rejectThenResolve() { - let attempt = 0; - return (workspaceId: string): Promise => { - attempt += 1; - if (attempt === 1) { - return Promise.reject(new Error("boom")); - } - return resolveDefault(workspaceId); - }; -} - -function nullThenResolve() { - let attempt = 0; - return (workspaceId: string): Promise => { - attempt += 1; - if (attempt === 1) { - return Promise.resolve(setupResult(workspaceId, null)); - } - return resolveDefault(workspaceId); - }; -} - -function mismatchThenResolve() { - let attempt = 0; - return (workspaceId: string): Promise => { - attempt += 1; - if (attempt === 1) { - return Promise.resolve(setupResult("999")); - } - return resolveDefault(workspaceId); - }; -} - -function ensureSetupStatus(client: WorkspaceSetupStatusClient) { - useWorkspaceSetupStore.getState().ensureSetupStatus({ - serverId: "server-1", - workspaceId: "42", - client, - }); -} - describe("workspace-setup-store", () => { beforeEach(() => { useWorkspaceSetupStore.setState({ pendingWorkspaceSetup: null, snapshots: {}, - requestedKeys: new Set(), surfacedFailedSetupKeys: new Set(), }); }); @@ -162,213 +46,39 @@ describe("workspace-setup-store", () => { expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toBeNull(); }); - it("hides empty successful setup snapshots", () => { - expect( - shouldShowWorkspaceSetup({ - workspaceId: "workspace-1", - status: "completed", - detail: { - type: "worktree_setup", - worktreePath: "/Users/test/project", - branchName: "main", - log: "", - commands: [], - }, - error: null, - updatedAt: Date.now(), - }), - ).toBe(false); - }); - - it("shows setup snapshots with commands or errors", () => { - expect( - shouldShowWorkspaceSetup({ - workspaceId: "workspace-1", - status: "completed", - detail: { - type: "worktree_setup", - worktreePath: "/Users/test/project", - branchName: "main", - log: "done\n", - commands: [ - { - index: 1, - command: "npm install", - cwd: "/Users/test/project", - log: "done\n", - status: "completed", - exitCode: 0, - }, - ], - }, - error: null, - updatedAt: Date.now(), - }), - ).toBe(true); - - expect( - shouldShowWorkspaceSetup({ - workspaceId: "workspace-1", - status: "failed", - detail: { - type: "worktree_setup", - worktreePath: "/Users/test/project", - branchName: "main", - log: "", - commands: [], - }, - error: "Failed to parse paseo.json", - updatedAt: Date.now(), - }), - ).toBe(true); - }); - - it("seeds a setup tab for failed or blocked setup", () => { - expect(shouldSeedWorkspaceSetupTab(null)).toBe(false); - expect(shouldSeedWorkspaceSetupTab(makeSnapshot("running"))).toBe(false); - expect(shouldSeedWorkspaceSetupTab(makeSnapshot("completed"))).toBe(false); - expect(shouldSeedWorkspaceSetupTab(makeSnapshot("failed", "Setup failed"))).toBe(true); - expect(shouldSeedWorkspaceSetupTab(makeSnapshot("blocked"))).toBe(true); - }); - - it("claims one failed setup surface until a later setup lifecycle begins", () => { - const store = useWorkspaceSetupStore.getState(); - const identity = { serverId: "server-1", workspaceId: "42" }; - - store.upsertProgress({ - serverId: identity.serverId, - payload: { ...makeSnapshot("failed", "Setup failed"), workspaceId: identity.workspaceId }, - }); - - expect(store.claimFailedSetupSurface(identity)).toBe(true); + it("uses directory metadata to surface failures without requesting setup details", () => { + for (const status of [null, "running", "completed", "failed", "blocked"] as const) { + expect(shouldShowWorkspaceSetup(status)).toBe(status !== null); + expect(shouldSeedWorkspaceSetupTab(status)).toBe(status === "failed" || status === "blocked"); + } + expect(shouldShowWorkspaceSetup(null, false)).toBe(true); + const identity = { serverId: "server-1", workspaceId: "42", status: "failed" as const }; + expect(useWorkspaceSetupStore.getState().snapshots).toEqual({}); + expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(true); expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(false); - - useWorkspaceSetupStore.getState().upsertProgress({ - serverId: identity.serverId, - payload: { ...makeSnapshot("running"), workspaceId: identity.workspaceId }, - }); - useWorkspaceSetupStore.getState().upsertProgress({ - serverId: identity.serverId, - payload: { - ...makeSnapshot("failed", "Setup failed again"), - workspaceId: identity.workspaceId, + useWorkspaceSetupStore.getState().applySetupStatus({ ...identity, status: "running" }); + expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(true); + expect(useWorkspaceSetupStore.getState().snapshots).toEqual({}); + }); + it("replaces cached setup details with an empty reconnect snapshot", () => { + const identity = { serverId: "server", workspaceId: "workspace" }; + const store = useWorkspaceSetupStore.getState(); + store.applySnapshot({ + ...identity, + snapshot: { + status: "failed", + error: "previous setup failed", + detail: { + type: "worktree_setup", + worktreePath: "/workspace", + branchName: "main", + commands: [], + log: "previous output", + }, }, }); - - expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(true); - }); - - it("ensureSetupStatus fetches setup status once and stores the snapshot", async () => { - const { client, calls } = makeClient(resolveDefault); - - ensureSetupStatus(client); - await flush(); - - expect(calls).toEqual(["42"]); - expect(storedSnapshots()).toEqual([ - expect.objectContaining({ workspaceId: "42", status: "running" }), - ]); - }); - - it("ensureSetupStatus does not refetch while a request is in flight", async () => { - const deferred = createDeferred(); - const { client, calls } = makeClient(() => deferred.promise); - - ensureSetupStatus(client); - ensureSetupStatus(client); - - expect(calls).toEqual(["42"]); - - deferred.resolve(setupResult("42")); - await flush(); - - ensureSetupStatus(client); - expect(calls).toEqual(["42"]); - }); - - it("ensureSetupStatus skips fetching when a snapshot already exists", () => { - useWorkspaceSetupStore.getState().upsertProgress({ - serverId: "server-1", - payload: { workspaceId: "42", ...DEFAULT_SNAPSHOT }, - }); - const { client, calls } = makeClient(resolveDefault); - - ensureSetupStatus(client); - - expect(calls).toEqual([]); - }); - - it("ensureSetupStatus ignores a response for a different workspace", async () => { - const { client } = makeClient(() => Promise.resolve(setupResult("999"))); - - ensureSetupStatus(client); - await flush(); - - expect(storedSnapshots()).toHaveLength(0); - }); - - it("ensureSetupStatus does not store a snapshot when the response snapshot is null", async () => { - const { client } = makeClient((workspaceId) => Promise.resolve(setupResult(workspaceId, null))); - - ensureSetupStatus(client); - await flush(); - - expect(storedSnapshots()).toHaveLength(0); - }); - - it("ensureSetupStatus retries after a null-snapshot response", async () => { - const { client, calls } = makeClient(nullThenResolve()); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42"]); - expect(storedSnapshots()).toHaveLength(0); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42", "42"]); - expect(storedSnapshots()).toHaveLength(1); - }); - - it("ensureSetupStatus retries after a mismatched-workspace response", async () => { - const { client, calls } = makeClient(mismatchThenResolve()); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42"]); - expect(storedSnapshots()).toHaveLength(0); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42", "42"]); - expect(storedSnapshots()).toHaveLength(1); - }); - - it("ensureSetupStatus clears the in-flight marker on error so a later call retries", async () => { - const { client, calls } = makeClient(rejectThenResolve()); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42"]); - expect(storedSnapshots()).toHaveLength(0); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42", "42"]); - expect(storedSnapshots()).toHaveLength(1); - }); - - it("ensureSetupStatus retries after the workspace is removed", async () => { - const { client, calls } = makeClient(resolveDefault); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42"]); - - useWorkspaceSetupStore.getState().removeWorkspace({ serverId: "server-1", workspaceId: "42" }); - ensureSetupStatus(client); - await flush(); - - expect(calls).toEqual(["42", "42"]); + expect(Object.values(useWorkspaceSetupStore.getState().snapshots)).toHaveLength(1); + store.applySnapshot({ ...identity, snapshot: null }); + expect(useWorkspaceSetupStore.getState().snapshots).toEqual({}); }); }); diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index d0205ba27a..2a40c04c80 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -1,6 +1,9 @@ import { createNameId } from "mnemonic-id"; import { generateDraftId } from "@/stores/draft-keys"; -import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { + WorkspaceSetupSnapshot as SetupDetailSnapshot, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; import { create } from "zustand"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; @@ -19,30 +22,18 @@ export type WorkspaceSetupProgressPayload = Extract< { type: "workspace_setup_progress" } >["payload"]; -export type WorkspaceSetupStatusResult = Extract< - SessionOutboundMessage, - { type: "workspace_setup_status_response" } ->["payload"]; - -export interface WorkspaceSetupStatusClient { - fetchWorkspaceSetupStatus: (workspaceId: string) => Promise; -} - export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload { updatedAt: number; } -export function shouldShowWorkspaceSetup(snapshot: WorkspaceSetupSnapshot | null): boolean { - if (!snapshot) { - return false; - } - return ( - snapshot.status === "blocked" || snapshot.error !== null || snapshot.detail.commands.length > 0 - ); +export type WorkspaceSetupStatus = WorkspaceSetupSnapshot["status"] | null; + +export function shouldShowWorkspaceSetup(status: WorkspaceSetupStatus, supported = true): boolean { + return !supported || status !== null; } -export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | null): boolean { - return snapshot?.status === "failed" || snapshot?.status === "blocked"; +export function shouldSeedWorkspaceSetupTab(status: WorkspaceSetupStatus): boolean { + return status === "failed" || status === "blocked"; } interface WorkspaceSetupStoreState { @@ -50,17 +41,24 @@ interface WorkspaceSetupStoreState { | (PendingWorkspaceSetup & { creationId: string; worktreeSlug: string }) | null; snapshots: Record; - requestedKeys: Set; surfacedFailedSetupKeys: Set; beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void; clearWorkspaceSetup: () => void; - upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void; - claimFailedSetupSurface: (input: { serverId: string; workspaceId: string }) => boolean; - ensureSetupStatus: (input: { + applySnapshot: (input: { serverId: string; workspaceId: string; - client: WorkspaceSetupStatusClient; + snapshot: SetupDetailSnapshot | null; }) => void; + applySetupStatus: (input: { + serverId: string; + workspaceId: string; + status: WorkspaceSetupStatus; + }) => void; + claimFailedSetupSurface: (input: { + serverId: string; + workspaceId: string; + status: WorkspaceSetupStatus; + }) => boolean; removeWorkspace: (input: { serverId: string; workspaceId: string }) => void; clearServer: (serverId: string) => void; } @@ -69,10 +67,9 @@ function buildWorkspaceSetupKey(input: { serverId: string; workspaceId: string } return buildWorkspaceTabPersistenceKey(input); } -export const useWorkspaceSetupStore = create()((set, get) => ({ +export const useWorkspaceSetupStore = create()((set) => ({ pendingWorkspaceSetup: null, snapshots: {}, - requestedKeys: new Set(), surfacedFailedSetupKeys: new Set(), beginWorkspaceSetup: (value) => { set({ @@ -86,30 +83,28 @@ export const useWorkspaceSetupStore = create()((set, g clearWorkspaceSetup: () => { set({ pendingWorkspaceSetup: null }); }, - upsertProgress: ({ serverId, payload }) => { - const key = buildWorkspaceSetupKey({ serverId, workspaceId: payload.workspaceId }); - if (!key) { - return; - } - + applySnapshot: ({ serverId, workspaceId, snapshot }) => { + const key = buildWorkspaceSetupKey({ serverId, workspaceId }); + if (!key) return; set((state) => { - const surfacedFailedSetupKeys = new Set(state.surfacedFailedSetupKeys); - if (payload.status !== "failed" && payload.status !== "blocked") { - surfacedFailedSetupKeys.delete(key); - } - return { - snapshots: { - ...state.snapshots, - [key]: { - ...payload, - updatedAt: Date.now(), - }, - }, - surfacedFailedSetupKeys, - }; + const snapshots = { ...state.snapshots }; + if (snapshot) snapshots[key] = { ...snapshot, workspaceId, updatedAt: Date.now() }; + else delete snapshots[key]; + return { snapshots }; }); }, - claimFailedSetupSurface: ({ serverId, workspaceId }) => { + applySetupStatus: ({ serverId, workspaceId, status }) => { + if (shouldSeedWorkspaceSetupTab(status)) return; + const key = buildWorkspaceSetupKey({ serverId, workspaceId }); + if (!key) return; + set((state) => { + if (!state.surfacedFailedSetupKeys.has(key)) return state; + const surfacedFailedSetupKeys = new Set(state.surfacedFailedSetupKeys); + surfacedFailedSetupKeys.delete(key); + return { surfacedFailedSetupKeys }; + }); + }, + claimFailedSetupSurface: ({ serverId, workspaceId, status }) => { const key = buildWorkspaceSetupKey({ serverId, workspaceId }); if (!key) { return false; @@ -117,10 +112,7 @@ export const useWorkspaceSetupStore = create()((set, g let claimed = false; set((state) => { - if ( - !["failed", "blocked"].includes(state.snapshots[key]?.status ?? "") || - state.surfacedFailedSetupKeys.has(key) - ) { + if (!shouldSeedWorkspaceSetupTab(status) || state.surfacedFailedSetupKeys.has(key)) { return state; } claimed = true; @@ -128,40 +120,6 @@ export const useWorkspaceSetupStore = create()((set, g }); return claimed; }, - ensureSetupStatus: async ({ serverId, workspaceId, client }) => { - const key = buildWorkspaceSetupKey({ serverId, workspaceId }); - if (!key) { - return; - } - const state = get(); - if (state.snapshots[key] || state.requestedKeys.has(key)) { - return; - } - - // requestedKeys is a pure in-flight marker: it dedupes concurrent fetches and is - // released once the request settles. A settle that stored no snapshot (null snapshot, - // mismatched workspace, or error) leaves no marker, so a later call can retry; once a - // snapshot lands, the snapshots[key] guard above prevents redundant refetches. - set((current) => ({ requestedKeys: new Set(current.requestedKeys).add(key) })); - - try { - const response = await client.fetchWorkspaceSetupStatus(workspaceId); - if (response.workspaceId === workspaceId && response.snapshot) { - get().upsertProgress({ - serverId, - payload: { workspaceId: response.workspaceId, ...response.snapshot }, - }); - } - } catch { - // Swallowed: the finally clears the in-flight marker so a later call retries. - } finally { - set((current) => { - const next = new Set(current.requestedKeys); - next.delete(key); - return { requestedKeys: next }; - }); - } - }, removeWorkspace: ({ serverId, workspaceId }) => { const key = buildWorkspaceSetupKey({ serverId, workspaceId }); if (!key) { diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index b679d5e694..407faafdf6 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -6,6 +6,7 @@ StyleSheet.configure({ breakpoints: { xs: 0, sm: 576, + medium: 600, md: 720, lg: 992, xl: 1200, @@ -21,6 +22,7 @@ type AppThemes = typeof REGISTERED_THEMES; interface AppBreakpoints { xs: number; sm: number; + medium: number; md: number; lg: number; xl: number; diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 1914993700..43f659c534 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -1,11 +1,12 @@ -import { useCallback, type ReactElement } from "react"; -import { Pressable, Text, View } from "react-native"; +import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; +import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { Archive, Unlink } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useProviderIcon } from "@/components/provider-icons"; import { ComposerTrackActions, ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome"; +import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; import { @@ -49,7 +50,6 @@ export interface SubagentsTrackProps { const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" }; -/** Leading and action glyphs share one size so rows keep a single icon column. */ const ROW_ICON_SIZE = 14; function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation { @@ -234,6 +234,7 @@ function SubagentsTrackRow({ void; onArchivePress: () => void; }): ReactElement { const { t } = useTranslation(); + const iconSize = paneContentToolbarIconSize(compact); return ( {onDetachPress ? ( + testID={`subagents-track-detach-${rowId}`} + > + + ) : null} + testID={`subagents-track-archive-${rowId}`} + > + + ); } -type SubagentActionIcon = "archive" | "detach"; - -function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement { - const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping; - if (icon === "detach") { - return ; - } - return ; -} - +/** The standard icon action, with a tooltip that names the action without repeating the row. */ function SubagentActionButton({ - accessibilityLabel, - testID, + label, tooltipLabel, - icon, - visible, + compact, onPress, + testID, + children, }: { - accessibilityLabel: string; - testID: string; + label: string; tooltipLabel: string; - icon: SubagentActionIcon; - visible: boolean; + compact: boolean; onPress: () => void; + testID: string; + children: ReactNode; }): ReactElement { + const tooltip = useMemo( + () => {tooltipLabel}, + [tooltipLabel], + ); return ( - - - - {({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)} - - - - {tooltipLabel} - - + + {children} + ); } @@ -415,11 +410,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], opacity: 0, }, - actionButton: { - padding: theme.spacing[1], - alignItems: "center", - justifyContent: "center", - }, tooltipText: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts index ecd2b44ffa..c188671ffe 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts @@ -151,6 +151,61 @@ describe("native terminal selection", () => { }); }); + it.each([ + { col: 0, expected: "한글", startCol: 0, endCol: 3 }, + { col: 1, expected: "한글", startCol: 0, endCol: 3 }, + { col: 2, expected: "한글", startCol: 0, endCol: 3 }, + { col: 3, expected: "한글", startCol: 0, endCol: 3 }, + { col: 5, expected: "문장", startCol: 5, endCol: 8 }, + { col: 6, expected: "문장", startCol: 5, endCol: 8 }, + { col: 7, expected: "문장", startCol: 5, endCol: 8 }, + { col: 8, expected: "문장", startCol: 5, endCol: 8 }, + ])( + "selects the whole Korean word from column $col, including continuation cells", + async ({ col, expected, startCol, endCol }) => { + const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 }); + try { + await terminal.write("한글 문장"); + const bounds = terminal.getBufferBounds(); + const row = bounds.cursorRow; + const selection = resolveTerminalWordSelection({ terminal, coordinate: { row, col } }); + + expect({ selection, text: extractTerminalSelectedText({ terminal, selection }) }).toEqual({ + selection: { + start: { row, col: startCol }, + end: { row, col: endCol }, + coordinateEpoch: bounds.coordinateEpoch, + }, + text: expected, + }); + } finally { + terminal.dispose(); + } + }, + ); + + it.each([ + { text: "한글 문장", col: 4, expected: "" }, + { text: "(한글)", col: 2, expected: "한글" }, + { text: "漢字 test", col: 1, expected: "漢字" }, + { text: "a한b x", col: 2, expected: "a한b" }, + { text: "🙂🙂 ok", col: 1, expected: "🙂🙂" }, + { text: "e\u0301한 ok", col: 2, expected: "e\u0301한" }, + { text: "한글", col: 3, expected: "한글" }, + ])("preserves word boundaries in $text at column $col", async ({ text, col, expected }) => { + const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 }); + try { + await terminal.write(text); + const selection = resolveTerminalWordSelection({ + terminal, + coordinate: { row: terminal.getBufferBounds().cursorRow, col }, + }); + expect(extractTerminalSelectedText({ terminal, selection })).toBe(expected); + } finally { + terminal.dispose(); + } + }); + it("maps screen coordinates through the current visible window", () => { expect( hitTestTerminalSelectionCell({ diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.ts b/packages/app/src/terminal/native-renderer/terminal-selection.ts index d222a4cc90..450a3813ac 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.ts @@ -1,4 +1,4 @@ -import type { NativeHeadlessTerminal } from "./headless-terminal-state"; +import type { NativeHeadlessTerminal, NativeTerminalCell } from "./headless-terminal-state"; import type { TerminalGridCellMetrics } from "./terminal-grid-metrics"; export interface TerminalBufferCoordinate { @@ -101,6 +101,10 @@ interface TerminalSelectionState { const TERMINAL_WORD_SEPARATORS = " ()[]{}',\"`"; +function isTerminalWordSeparator(cell: NativeTerminalCell): boolean { + return cell.width !== 0 && TERMINAL_WORD_SEPARATORS.includes(cell.char || " "); +} + function compareCoordinates( left: TerminalBufferCoordinate, right: TerminalBufferCoordinate, @@ -183,8 +187,7 @@ export function resolveTerminalWordSelection( if (!cells || input.coordinate.col < 0 || input.coordinate.col >= cells.length) { return null; } - const target = cells[input.coordinate.col]?.char || " "; - if (TERMINAL_WORD_SEPARATORS.includes(target)) { + if (isTerminalWordSeparator(cells[input.coordinate.col])) { return { start: input.coordinate, end: input.coordinate, @@ -195,13 +198,11 @@ export function resolveTerminalWordSelection( let startCol = input.coordinate.col; let endCol = input.coordinate.col; while (startCol > 0) { - const previous = cells[startCol - 1]?.char || " "; - if (TERMINAL_WORD_SEPARATORS.includes(previous)) break; + if (isTerminalWordSeparator(cells[startCol - 1])) break; startCol -= 1; } while (endCol + 1 < cells.length) { - const next = cells[endCol + 1]?.char || " "; - if (TERMINAL_WORD_SEPARATORS.includes(next)) break; + if (isTerminalWordSeparator(cells[endCol + 1])) break; endCol += 1; } diff --git a/packages/app/src/usage/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx index 0199c08ff2..bf10054db6 100644 --- a/packages/app/src/usage/agent-usage.tsx +++ b/packages/app/src/usage/agent-usage.tsx @@ -1,7 +1,7 @@ +import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { UsageCard } from "./card"; -import { usageCopy } from "./copy"; import { useUsageDisplay } from "./display"; import { useAgentUsage } from "./queries"; import type { UsageReportEntry } from "./types"; @@ -21,6 +21,7 @@ export function AgentUsage({ /** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */ refreshable: boolean; }) { + const { t } = useTranslation(); const view = useAgentUsage(serverId, agentId); if (view.kind === "none") return null; return ( @@ -35,7 +36,7 @@ export function AgentUsage({ /> ) : ( - {view.kind === "loading" ? usageCopy.loading : view.message} + {view.kind === "loading" ? t("providerUsage.loading") : view.message} )} diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index 38b2f0804a..def723c554 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -1,16 +1,26 @@ import { useMemo } from "react"; +import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { clampPct, formatAmount, formatResetLabel } from "./format"; +import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format"; import type { UsageBalance, UsageTone } from "./types"; +import { useRelativeTimeTick } from "./use-relative-time-tick"; interface ResolvedBalance { amountText: string; usedPct: number | null; } -function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance { +function resolveBalance({ + balance, + t, + locale, +}: { + balance: UsageBalance; + t: TFunction; + locale?: string; +}): ResolvedBalance { const { used, remaining, limit, unit } = balance; const format = (value: number) => formatAmount(value, unit, locale); if (limit != null && limit > 0) { @@ -20,7 +30,10 @@ function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance return { amountText: `${usedText} / ${format(limit)}`, usedPct }; } if (remaining != null) { - return { amountText: `${format(remaining)} left`, usedPct: null }; + return { + amountText: t("providerUsage.values.remaining", { amount: format(remaining) }), + usedPct: null, + }; } if (used != null) { return { amountText: format(used), usedPct: null }; @@ -48,10 +61,11 @@ export function UsageBalanceBar({ balance: UsageBalance; retained?: boolean; }) { - const { i18n } = useTranslation(); - const { amountText, usedPct } = resolveBalance(balance, i18n.language); + const { t, i18n } = useTranslation(); + const now = useRelativeTimeTick({ enabled: balance.resetsAt != null }); + const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage }); const tone = balance.tone ?? "default"; - const resetLabel = formatResetLabel(balance.resetsAt, retained); + const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t); const fillStyle = useMemo>( () => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }], @@ -62,12 +76,18 @@ export function UsageBalanceBar({ - {balance.label} - - - {amountText} - {resetLabel ? {` · ${resetLabel}`} : null} + {formatProviderUsageLabel(balance.id, balance.label, t)} + + + {amountText} + + {resetLabel ? ( + + {resetLabel} + + ) : null} + {usedPct != null ? ( @@ -94,13 +114,19 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, value: { + flexShrink: 1, + alignItems: "flex-end", + }, + amount: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, reset: { color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, + textAlign: "right", }, track: { height: 4, diff --git a/packages/app/src/usage/bars.browser.test.tsx b/packages/app/src/usage/bars.browser.test.tsx new file mode 100644 index 0000000000..7b978f7ced --- /dev/null +++ b/packages/app/src/usage/bars.browser.test.tsx @@ -0,0 +1,53 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { i18n } from "@/i18n/i18next"; +import { UsageWindowBar } from "./window-bar"; +import type { UsageBalance, UsageWindow } from "./types"; +import { UsageBalanceBar } from "./balance-bar"; + +const window: UsageWindow = { id: "session", label: "Session", usedPct: 70 }; +const balance: UsageBalance = { id: "credits", label: "Credits", unit: "usd", remaining: 12 }; +const ignorePin = () => {}; + +let root: Root; +let container: HTMLDivElement; +beforeEach(async () => { + vi.stubGlobal("React", React); + await i18n.changeLanguage("en"); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); +afterEach(async () => { + act(() => root.unmount()); + container.remove(); + await i18n.changeLanguage("en"); + vi.unstubAllGlobals(); +}); + +it("translates mounted usage rows when the language changes", async () => { + act(() => + root.render( + <> + + + , + ), + ); + expect(container.textContent).toContain("Session70% used"); + expect(container.textContent).toContain("Credits"); + await act(async () => { + await i18n.changeLanguage("ja"); + }); + expect(container.textContent).toContain("セッション70% 使用済み"); + expect(container.textContent).toContain("クレジット"); +}); diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index 562f3805a1..7d0f9903da 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -1,5 +1,6 @@ import { RotateCw } from "lucide-react-native"; import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Text, View, type StyleProp, type TextStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { @@ -12,19 +13,22 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane- import { StatusBadge } from "@/components/ui/status-badge"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; -import { useCompactTimeAgo } from "@/hooks/use-time-ago"; +import type { TFunction } from "i18next"; import { UsageBalanceBar } from "./balance-bar"; -import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; -import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model"; +import { formatAgo, formatProviderUsageLabel } from "./format"; +import { resolveUsageReport, type UsageRefresh } from "./model"; import { useReportRefresh } from "./queries"; import { UsageSourceIcon } from "./source-icon"; import type { UsageReport, UsageReportEntry, UsageWindow } from "./types"; import { UsageWindowBar } from "./window-bar"; +import { useRelativeTimeTick } from "./use-relative-time-tick"; -function statusText(report: UsageReport): string | null { +function statusText(report: UsageReport, t: TFunction): string | null { if (report.status === "available") return null; - return report.status === "error" ? "Error" : "Unavailable"; + return report.status === "error" + ? t("providerUsage.status.error") + : t("providerUsage.status.unavailable"); } const ThemedRotateCw = withUnistyles(RotateCw); @@ -49,15 +53,16 @@ export function UsageCard({ refreshable: boolean; compact?: boolean; }) { + const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId); // Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip. const showsFreshnessInline = isNative || isCompact || !refreshable; const usage = entry.report; - const status = statusText(usage); + const status = statusText(usage, t); const footer = entry.account.label ?? null; const { windows, balances, details, planLabel, successfulAt, retained, messages } = - resolveUsageReport(entry); + resolveUsageReport(entry, t); const containerStyle = useMemo( () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], @@ -156,7 +161,7 @@ export function UsageCard({ {refreshState === "failed" ? ( - {usageCopy.refreshFailed} + {t("providerUsage.refreshFailed")} ) : null} @@ -176,6 +181,7 @@ function CardWindowBar({ display: UsageDisplay; pinnable: boolean; }) { + const { t } = useTranslation(); const pin = useMemo( () => ({ sourceId: entry.sourceId, windowId: window.id }), [entry.sourceId, window.id], @@ -190,7 +196,7 @@ function CardWindowBar({ pinnable={pinnable} pinned={display.isPinned(pin)} onTogglePin={toggle} - pinLabel={`${usageCopy.pin} ${entry.sourceLabel} ${window.label}`} + pinLabel={`${t("providerUsage.pin")} ${entry.sourceLabel} ${formatProviderUsageLabel(window.id, window.label, t)}`} pinTestID={`usage-pin-${entry.sourceId}-${window.id}`} /> ); @@ -212,6 +218,7 @@ function UsageRefreshButton({ onRefresh: () => void; compact: boolean; }) { + const { t } = useTranslation(); const isPending = refreshState === "pending"; const iconSize = paneContentToolbarIconSize(compact); const freshness = useMemo( @@ -227,7 +234,7 @@ function UsageRefreshButton({ ); return ( ; testID: string; }) { - const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null); + const { t } = useTranslation(); + const now = useRelativeTimeTick({ enabled: successfulAt != null }); + const relativeTime = formatAgo(successfulAt, now, t); + const freshness = relativeTime + ? t(retained ? "providerUsage.timing.lastUpdated" : "providerUsage.timing.updated", { + relativeTime, + }) + : t("providerUsage.timing.noSuccessfulUpdate"); return ( - {successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"} + {freshness} ); } diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index 639272283b..14bcfe7d95 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -1,40 +1,23 @@ +import type { TFunction } from "i18next"; import type { UsageProblem } from "@getpaseo/protocol/messages"; -import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time"; +import { i18n } from "@/i18n/i18next"; +import { formatAgo } from "./format"; -// User-facing copy for the usage surfaces, kept in one file so localization is a -// single-file change. +// Messages for usage failures outside the UI. Rendered copy uses useTranslation. export const usageCopy = { - problem: (problem: UsageProblem, now: Date = new Date()): string => { + problem: (problem: UsageProblem, now: Date = new Date(), t: TFunction = i18n.t): string => { if (problem.kind === "no_quota") return problem.detail; const remedy = problem.refreshedBy - ? `Run ${problem.refreshedBy} to refresh it.` - : "Sign in again."; - if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`; - const ago = formatCompactTimeAgoAsProse(formatCompactTimeAgo(new Date(problem.expiresAt), now)); - return `Login expired ${ago}. ${remedy}`; + ? t("providerUsage.runToRefresh", { command: problem.refreshedBy }) + : t("providerUsage.signInAgain"); + if (problem.kind === "rejected") + return t("providerUsage.loginRejected", { status: problem.status, remedy }); + return t("providerUsage.loginExpired", { + relativeTime: formatAgo(problem.expiresAt, now.getTime(), t), + remedy, + }); + }, + get clientUnavailable() { + return i18n.t("providerUsage.clientUnavailable"); }, - title: "Usage", - planUsage: "Plan usage", - options: "Settings", - refresh: "Refresh", - refreshAll: "Refresh all", - refreshing: "Refreshing...", - refreshFailed: "Unable to refresh usage", - updated: "Updated", - loading: "Loading usage...", - empty: "No usage data", - noHosts: "No connected hosts", - errorTitle: "Unable to load usage", - agentError: (reason: string) => `Unable to load usage: ${reason}`, - hostUnavailable: (host: string) => `Connect to ${host} to see usage`, - hostUpgradeRequired: (host: string) => `Update ${host} to see usage`, - clientUnavailable: "Host connection is not ready", - retry: "Try again", - pin: "Pin", - unpin: "Unpin", - displayAs: "Percentages", - displayUsed: "Used", - displayRemaining: "Remaining", - showInSidebar: "Summary in sidebar", - showInSidebarHint: "Pinned windows show in the sidebar footer", } as const; diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index 22529c8d71..b7b81f0e5a 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -1,7 +1,25 @@ -import { describe, expect, it } from "vitest"; -import { formatAmount, formatResetLabel } from "./format"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; +import { + formatDisplayPct, + formatAgo, + formatAmount, + formatPct, + formatProviderUsageLabel, + formatResetLabel, + formatRunsOutLabel, +} from "./format"; + +const NOW = Date.parse("2026-07-19T00:00:00.000Z"); describe("formatAmount", () => { + beforeEach(async () => { + await i18n.changeLanguage("en"); + }); + + afterEach(async () => { + await i18n.changeLanguage("en"); + }); it("groups thousands in the app's language", () => { expect(formatAmount(12345, "credits", "en")).toBe("12,345"); expect(formatAmount(12345, "requests", "en")).toBe("12,345"); @@ -12,6 +30,68 @@ describe("formatAmount", () => { expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50"); expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US"); }); + + it("localizes usage timing without patching the global clock", async () => { + const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString(); + const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString(); + + expect(formatPct(7, "en")).toBe("7%"); + expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h"); + expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h"); + + await i18n.changeLanguage("ja"); + expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット"); + expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("2時間後に上限に到達"); + expect(formatAgo(threeDaysAgo, NOW)).toBe("3日前"); + }); + + it("formats percentages with locale-specific spacing", () => { + expect(formatPct(42, "fr")).toBe("42\u00a0%"); + }); + + it("formats relative-time counts with the active locale", async () => { + await i18n.changeLanguage("fr"); + const days = 1_234; + const daysFromNow = new Date(NOW + days * 24 * 60 * 60 * 1000).toISOString(); + const daysAgo = new Date(NOW - days * 24 * 60 * 60 * 1000).toISOString(); + + expect(formatResetLabel(daysFromNow, false, NOW)).toBe("se réinitialise dans 1\u202f234 j"); + expect(formatRunsOutLabel(daysFromNow, NOW)).toBe("s’épuise dans 1\u202f234 j"); + expect(formatAgo(daysAgo, NOW)).toBe("il y a 1\u202f234 j"); + }); + + it("selects Arabic plural forms for relative durations", async () => { + await i18n.changeLanguage("ar"); + const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString(); + const threeHoursFromNow = new Date(NOW + 3 * 60 * 60 * 1000).toISOString(); + const twoDaysAgo = new Date(NOW - 2 * 24 * 60 * 60 * 1000).toISOString(); + + expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("تتم إعادة التعيين خلال ساعتين"); + expect(formatRunsOutLabel(threeHoursFromNow, NOW)).toBe("ينفد خلال 3 ساعات"); + expect(formatAgo(twoDaysAgo, NOW)).toBe("قبل يومين"); + }); + + it("uses locale-aware compact notation for token balances", () => { + expect(formatAmount(1_234, "tokens", "en")).toBe("1.2K"); + expect(formatAmount(1_234, "tokens", "ar")).toBe("1.2\u00a0ألف"); + }); + + it("localizes known provider-usage labels and preserves provider-specific labels", async () => { + await i18n.changeLanguage("ko"); + + expect(formatProviderUsageLabel("session", "Session")).toBe("세션"); + expect(formatProviderUsageLabel("five_hour", "Session")).toBe("세션"); + expect(formatProviderUsageLabel("five_hour", "5-hour limit")).toBe("5-hour limit"); + expect(formatProviderUsageLabel("monthly", "Monthly")).toBe("월간"); + expect(formatProviderUsageLabel("code_review:weekly", "Code review · Weekly")).toBe( + "코드 검토 · 주간", + ); + expect(formatProviderUsageLabel("code_review:five_hour", "Code review · 5-hour")).toBe( + "코드 검토 · 5-hour", + ); + expect(formatProviderUsageLabel("credits", "Credits")).toBe("크레딧"); + expect(formatProviderUsageLabel("custom_limit", "Custom limit")).toBe("Custom limit"); + }); }); it("retained reset times that have passed do not claim the quota is resetting", () => { @@ -20,3 +100,33 @@ it("retained reset times that have passed do not claim the quota is resetting", expect(formatResetLabel(past)).toBe("resetting now"); expect(formatResetLabel(null, true)).toBeNull(); }); + +it("says that a usage window percentage is consumed", () => { + expect(formatDisplayPct(70, "used")).toBe("70% used"); + expect(formatDisplayPct(30, "remaining")).toBe("30% left"); +}); +it("translates the usage refresh control into Japanese", async () => { + try { + await i18n.changeLanguage("ja"); + expect(i18n.t("providerUsage.refresh")).toBe("更新"); + } finally { + await i18n.changeLanguage("en"); + } +}); + +it("translates the neighboring usage percentage settings", async () => { + try { + await i18n.changeLanguage("ja"); + expect(i18n.t("providerUsage.displayUsed")).toBe("使用済み"); + expect(i18n.t("providerUsage.displayRemaining")).toBe("残り"); + } finally { + await i18n.changeLanguage("en"); + } +}); + +it("preserves provider-defined labels even when their IDs match a standard period", () => { + expect(formatProviderUsageLabel("monthly", "Extra allotment")).toBe("Extra allotment"); + expect(formatProviderUsageLabel("code_review:weekly", "Custom review allowance")).toBe( + "Custom review allowance", + ); +}); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index e13a550e7b..e7a6ba6f7b 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -1,47 +1,163 @@ -import { formatTokenCount } from "@/components/context-window-meter.utils"; +import type { TFunction } from "i18next"; +import { i18n } from "@/i18n/i18next"; import type { UsageDisplayAs } from "./preferences"; import type { UsageBalanceUnit } from "./types"; +const providerUsageLabels = { + session: { label: "Session", key: "providerUsage.labels.session" }, + weekly: { label: "Weekly", key: "providerUsage.labels.weekly" }, + monthly: { label: "Monthly", key: "providerUsage.labels.monthly" }, + code_review: { label: "Code review", key: "providerUsage.labels.codeReview" }, + credits: { label: "Credits", key: "providerUsage.labels.credits" }, + monthly_credits: { label: "Monthly credits", key: "providerUsage.labels.monthlyCredits" }, +} as const; + +export function formatProviderUsageLabel(id: string, label: string, t: TFunction = i18n.t): string { + const reviewPrefix = "Code review · "; + if (id.startsWith("code_review:") && label.startsWith(reviewPrefix)) { + const periodId = id.slice("code_review:".length); + const periodLabel = label.slice(reviewPrefix.length); + return `${t(providerUsageLabels.code_review.key)} · ${formatProviderUsageLabel(periodId, periodLabel, t)}`; + } + const standard = + providerUsageLabels[id as keyof typeof providerUsageLabels] ?? + (id === "five_hour" ? providerUsageLabels.session : undefined); + return standard?.label === label ? t(standard.key) : label; +} + export function clampPct(value: number): number { return Math.max(0, Math.min(100, value)); } -export function formatPct(value: number): string { - return `${Math.round(clampPct(value))}%`; +export function formatPct(value: number, locale?: string): string { + const resolvedLocale = locale ?? i18n.resolvedLanguage; + return new Intl.NumberFormat(resolvedLocale, { + style: "percent", + maximumFractionDigits: 0, + }).format(clampPct(value) / 100); } -/** "31%" of the window used, or "69% left" of it. */ -export function formatDisplayPct(value: number, displayAs: UsageDisplayAs): string { - return displayAs === "used" ? formatPct(value) : `${formatPct(value)} left`; +/** A localized percentage that says whether it is used or remaining. */ +export function formatDisplayPct( + value: number, + displayAs: UsageDisplayAs, + locale?: string, + t: TFunction = i18n.t, +): string { + const percentage = formatPct(value, locale); + return displayAs === "used" + ? t("providerUsage.values.used", { percentage }) + : t("providerUsage.values.remaining", { amount: percentage }); } -function relativeDuration(iso: string): string | null { - const diffMs = new Date(iso).getTime() - Date.now(); +type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number }; + +function formatCount(value: number): string { + return new Intl.NumberFormat(i18n.resolvedLanguage).format(value); +} + +function relativeDuration(iso: string, now: number): RelativeDuration | null { + const diffMs = new Date(iso).getTime() - now; if (!Number.isFinite(diffMs)) return null; - if (diffMs <= 0) return "now"; + if (diffMs <= 0) return { unit: "now" }; const diffMinutes = Math.floor(diffMs / 60_000); const diffHours = Math.floor(diffMinutes / 60); const diffDays = Math.floor(diffHours / 24); - if (diffDays > 0) return `${diffDays}d`; - if (diffHours > 0) return `${diffHours}h`; - return `${diffMinutes}m`; + if (diffDays > 0) return { unit: "days", count: diffDays }; + if (diffHours > 0) return { unit: "hours", count: diffHours }; + return { unit: "minutes", count: diffMinutes }; } -export function formatResetLabel(iso: string | null | undefined, retained = false): string | null { +function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null { + switch (duration.unit) { + case "now": + return null; + case "days": + return t("providerUsage.duration.days", { + count: duration.count, + value: formatCount(duration.count), + }); + case "hours": + return t("providerUsage.duration.hours", { + count: duration.count, + value: formatCount(duration.count), + }); + case "minutes": + return t("providerUsage.duration.minutes", { + count: duration.count, + value: formatCount(duration.count), + }); + } +} + +export function formatResetLabel( + iso: string | null | undefined, + retained = false, + now = Date.now(), + t: TFunction = i18n.t, +): string | null { if (!iso) return null; - const rel = relativeDuration(iso); + const rel = relativeDuration(iso, now); if (!rel) return null; - if (rel === "now" && retained) return "Reset time passed"; - return rel === "now" ? "resetting now" : `resets ${rel}`; + if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed"); + if (rel.unit === "now") return t("providerUsage.timing.resettingNow"); + const duration = formatRelativeDuration(rel, t); + return duration ? t("providerUsage.timing.resetsIn", { duration }) : null; +} + +export function formatRunsOutLabel( + iso: string | null | undefined, + now = Date.now(), + t: TFunction = i18n.t, +): string | null { + if (!iso) return null; + const rel = relativeDuration(iso, now); + if (!rel) return null; + if (rel.unit === "now") return t("providerUsage.timing.runsOutNow"); + const duration = formatRelativeDuration(rel, t); + return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null; +} + +export function formatAgo( + iso: string | null | undefined, + now = Date.now(), + t: TFunction = i18n.t, +): string | null { + if (!iso) return null; + const diffMs = now - new Date(iso).getTime(); + if (!Number.isFinite(diffMs)) return null; + if (diffMs < 60_000) return t("providerUsage.timing.justNow"); + const diffMinutes = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + if (diffDays > 0) { + return t("providerUsage.timing.daysAgo", { + count: diffDays, + value: formatCount(diffDays), + }); + } + if (diffHours > 0) { + return t("providerUsage.timing.hoursAgo", { + count: diffHours, + value: formatCount(diffHours), + }); + } + return t("providerUsage.timing.minutesAgo", { + count: diffMinutes, + value: formatCount(diffMinutes), + }); } /** A balance amount as the app's language writes it: "$1,234.50", "12,345". */ -export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string { +export function formatAmount(value: number, unit: UsageBalanceUnit, locale?: string): string { switch (unit) { case "usd": return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value); case "tokens": - return formatTokenCount(value); + return new Intl.NumberFormat(locale, { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); default: return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value); } diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx index 927d32e7d8..e80e839680 100644 --- a/packages/app/src/usage/host-usage-section.tsx +++ b/packages/app/src/usage/host-usage-section.tsx @@ -1,16 +1,17 @@ -import { usageCopy } from "./copy"; +import { useTranslation } from "react-i18next"; import { useUsagePreferences } from "./display"; import { useHostUsage } from "./queries"; import { UsageSection } from "./usage-section"; /** A host's usage reports, for its settings page. */ export function HostUsageSection({ serverId }: { serverId: string }) { + const { t } = useTranslation(); const { view, refresh } = useHostUsage(serverId); const { display } = useUsagePreferences(serverId); return ( { ).toEqual({ kind: "unavailable", message: "Connect to Laptop to see usage" }); }); + it("localizes unavailable host messages while retaining the host name", async () => { + await i18n.changeLanguage("ko"); + expect( + resolveUsageView({ + hostLabel: "Laptop", + isConnected: false, + supportsUsage: false, + query: undefined, + }), + ).toEqual({ kind: "unavailable", message: "Laptop에 연결하여 제공업체 사용량을 확인하세요" }); + }); + it("moves from loading to ready to error", () => { const base = { hostLabel: "Laptop", isConnected: true, supportsUsage: true }; expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" }); @@ -80,6 +92,10 @@ describe("resolveUsageView", () => { }); }); +afterEach(async () => { + await i18n.changeLanguage("en"); +}); + const hosts: UsageHost[] = [ { serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true }, { serverId: "old", label: "Old", isConnected: true, supportsUsage: false }, @@ -185,14 +201,6 @@ describe("displayPercent", () => { }); }); -describe("formatUsageFreshness", () => { - it("says when the report was fetched", () => { - expect(formatUsageFreshness("now")).toBe("Updated just now"); - expect(formatUsageFreshness("3m")).toBe("Updated 3m ago"); - expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15"); - }); -}); - describe("resolveUsageRefresh", () => { it("is pending while a refresh runs, even after an earlier failure", () => { expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending"); @@ -338,7 +346,7 @@ it("resolves retained usage and successful time without inventing old-daemon his report: success.report as Extract, }, }; - expect(resolveUsageReport(failure)).toEqual({ + expect(resolveUsageReport(failure, i18n.t)).toEqual({ windows: success.report.status === "available" ? success.report.windows : [], balances: [], details: [], @@ -347,7 +355,7 @@ it("resolves retained usage and successful time without inventing old-daemon his retained: true, messages: ["Rate limited"], }); - expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({ + expect(resolveUsageReport({ ...failure, lastSuccessful: undefined }, i18n.t)).toEqual({ windows: [], balances: [], details: [], @@ -356,7 +364,7 @@ it("resolves retained usage and successful time without inventing old-daemon his retained: false, messages: ["Rate limited"], }); - expect(resolveUsageReport(success)).toEqual({ + expect(resolveUsageReport(success, i18n.t)).toEqual({ windows: success.report.status === "available" ? success.report.windows : [], balances: [], details: [], @@ -384,7 +392,7 @@ it("orders failed polls by attempt time even when successful data is older", () it("available and retained usage show the same successful completion time", () => { const success: UsageReportEntry = { ...entry({}), successfulAt: "2026-01-01T00:00:15.000Z" }; if (success.report.status !== "available") throw new Error("fixture must be available"); - expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z"); + expect(resolveUsageReport(success, i18n.t).successfulAt).toBe("2026-01-01T00:00:15.000Z"); const failure: UsageReportEntry = { ...success, successfulAt: undefined, @@ -392,6 +400,8 @@ it("available and retained usage show the same successful completion time", () = report: { status: "error", error: "Rate limited" }, lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report }, }; - expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt); + expect(resolveUsageReport(failure, i18n.t).successfulAt).toBe( + resolveUsageReport(success, i18n.t).successfulAt, + ); expect(upsertReport([failure], success)).toEqual([failure]); }); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 1f1c82cce3..48143ad23f 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -1,4 +1,5 @@ -import { formatCompactTimeAgoAsProse } from "@/utils/time"; +import type { TFunction } from "i18next"; +import { i18n } from "@/i18n/i18next"; import { usageCopy } from "./copy"; import type { UsageDisplayAs } from "./preferences"; import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types"; @@ -31,11 +32,6 @@ export function usageWindowRowLabel(input: { return `${input.pinLabel}, ${summary}`; } -/** When a report was fetched, from its compact relative time: "Updated 3m ago". */ -export function formatUsageFreshness(compactTimeAgo: string, retained = false): string { - return `${retained ? "Last updated" : usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; -} - /** A user-requested refresh of one report. The previous report stays on screen throughout. */ export type UsageRefresh = "idle" | "pending" | "failed"; @@ -108,16 +104,26 @@ export interface UsageQueryState { isFetching: boolean; } -export function resolveUsageView(input: { - hostLabel: string; - isConnected: boolean; - supportsUsage: boolean; - query: UsageQueryState | undefined; -}): UsageView { +export function resolveUsageView( + input: { + hostLabel: string; + isConnected: boolean; + supportsUsage: boolean; + query: UsageQueryState | undefined; + }, + t: TFunction = i18n.t, +): UsageView { const { hostLabel, isConnected, supportsUsage, query } = input; - if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable(hostLabel) }; + if (!isConnected) + return { + kind: "unavailable", + message: t("providerUsage.hostUnavailable", { host: hostLabel }), + }; if (!supportsUsage) { - return { kind: "unavailable", message: usageCopy.hostUpgradeRequired(hostLabel) }; + return { + kind: "unavailable", + message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }), + }; } if (query?.data) { return { kind: "ready", reports: query.data, isRefreshing: query.isFetching }; @@ -138,17 +144,20 @@ export type AgentUsageView = | { kind: "error"; message: string } | { kind: "ready"; reports: UsageReportEntry[] }; -export function resolveAgentUsageView(input: { - canReport: boolean; - query: UsageQueryState; -}): AgentUsageView { +export function resolveAgentUsageView( + input: { + canReport: boolean; + query: UsageQueryState; + }, + t: TFunction = i18n.t, +): AgentUsageView { const { canReport, query } = input; if (!canReport) return { kind: "none" }; // A failed request keeps the reports from before it, or those that streamed in before it failed; // shown alone they would pass for the agent's complete, current usage. if (query.error) { const reason = query.error instanceof Error ? query.error.message : String(query.error); - return { kind: "error", message: usageCopy.agentError(reason) }; + return { kind: "error", message: t("providerUsage.agentError", { reason }) }; } if (query.data) { return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data }; @@ -194,7 +203,7 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null } /** Current outcome owns messages; only a successful poll owns values and freshness. */ -export function resolveUsageReport(entry: UsageReportEntry) { +export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) { const available = entry.report.status === "available"; // COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0. // Older daemons cannot provide history; a failed attempt is never a successful update. @@ -204,10 +213,12 @@ export function resolveUsageReport(entry: UsageReportEntry) { if (!available) { if (entry.loginErrors) messages.push( - ...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`), + ...entry.loginErrors.map( + (login) => `${login.harness}: ${usageProblemText(login.report, t)}`, + ), ); // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. - else messages.push(usageProblemText(entry.report)); + else messages.push(usageProblemText(entry.report, t)); } return { windows: content?.windows ?? [], @@ -224,8 +235,8 @@ export function resolveUsageReport(entry: UsageReportEntry) { }; } -function usageProblemText(report: UsageReport): string { +function usageProblemText(report: UsageReport, t: TFunction): string { if (report.status === "error") return report.error; - if (report.status === "unavailable") return usageCopy.problem(report.problem); + if (report.status === "unavailable") return usageCopy.problem(report.problem, new Date(), t); return ""; } diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx index 886c21e190..b735b612aa 100644 --- a/packages/app/src/usage/options.tsx +++ b/packages/app/src/usage/options.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { Settings } from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; @@ -8,20 +9,23 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { SettingsRow, SettingsSwitch } from "@/components/settings"; import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; import { settingsStyles } from "@/styles/settings"; -import { usageCopy } from "./copy"; import { useUsagePreferences } from "./display"; import { useUsageInSidebar } from "./in-sidebar"; import type { UsageDisplayAs } from "./preferences"; -const DISPLAY_AS_OPTIONS: SegmentedControlOption[] = [ - { value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" }, - { value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" }, -]; - const ThemedSettings = withUnistyles(Settings); /** The shared usage preferences, opened from a cog as a popover or compact sheet. */ export function UsageOptions() { + const { t } = useTranslation(); + const displayAsOptions: SegmentedControlOption[] = [ + { value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" }, + { + value: "remaining", + label: t("providerUsage.displayRemaining"), + testID: "usage-display-remaining", + }, + ]; const compact = useIsCompactFormFactor(); const { display } = useUsagePreferences(); const { inSidebar, setInSidebar } = useUsageInSidebar(); @@ -29,7 +33,7 @@ export function UsageOptions() { @@ -41,21 +45,21 @@ export function UsageOptions() { - + { + const { i18n } = await import("@/i18n/i18next"); + await i18n.changeLanguage("ja"); + try { + const entries = [ + report({ + sourceId: "test", + sourceLabel: "Test", + windows: [ + { id: "session", label: "Session", summary: true, usedPct: 70 }, + { id: "weekly", label: "My allowance", summary: true, usedPct: 20 }, + ], + }), + ]; + expect(windows(entries, preferences(null)).map((w) => [w.label, w.shortLabel])).toEqual([ + ["Test セッション 70% 使用済み", "セッション"], + ["Test My allowance 20% 使用済み", "My allowance"], + ]); + expect(windows(entries, preferences(null, "remaining"))[0].label).toBe( + "Test セッション 残り 30%", + ); + } finally { + await i18n.changeLanguage("en"); + } +}); diff --git a/packages/app/src/usage/pinned.ts b/packages/app/src/usage/pinned.ts index 547b2c3e48..21fd4cf06c 100644 --- a/packages/app/src/usage/pinned.ts +++ b/packages/app/src/usage/pinned.ts @@ -1,4 +1,6 @@ -import { formatPct } from "./format"; +import type { TFunction } from "i18next"; +import { i18n } from "@/i18n/i18next"; +import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format"; import { displayPercent } from "./model"; import { effectiveUsagePins, type UsagePreferences } from "./preferences"; import { windowTone } from "./tone"; @@ -68,9 +70,9 @@ function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[] export function resolvePinnedUsage( reports: readonly UsageReportEntry[], preferences: UsagePreferences, + t: TFunction = i18n.t, ): PinnedUsageSource[] { const pins = effectiveUsagePins(preferences, reports); - const meaning = preferences.displayAs === "remaining" ? "left" : "used"; return groupBySource(reports).flatMap((entry) => { if (entry.report.status !== "available") return []; const windows = entry.report.windows @@ -84,8 +86,8 @@ export function resolvePinnedUsage( const percentText = formatPct(percent); return { key: `${entry.id}/${window.id}`, - label: `${describe(entry, window.label)} ${percentText} ${meaning}`, - shortLabel: window.shortLabel ?? window.label, + label: `${describe(entry, formatProviderUsageLabel(window.id, window.label, t))} ${formatDisplayPct(percent, preferences.displayAs, undefined, t)}`, + shortLabel: window.shortLabel ?? formatProviderUsageLabel(window.id, window.label, t), percent, percentText, tone: windowTone(window), diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index 7b6f210543..c5ec32bd2d 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client"; import { useCallback, useMemo } from "react"; import { @@ -144,13 +145,17 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = const refresh = useCallback(() => { void refreshReports(queryClient, serverId).catch(() => undefined); }, [queryClient, serverId]); + const { t } = useTranslation(); const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId; - const view = resolveUsageView({ - hostLabel, - isConnected, - supportsUsage: isSupported, - query: toQueryState(query), - }); + const view = resolveUsageView( + { + hostLabel, + isConnected, + supportsUsage: isSupported, + query: toQueryState(query), + }, + t, + ); return { view, refresh }; } @@ -196,7 +201,8 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView retry: false, staleTimeMs: REPORTS_STALE_TIME_MS, }); - return resolveAgentUsageView({ canReport, query: toQueryState(query) }); + const { t } = useTranslation(); + return resolveAgentUsageView({ canReport, query: toQueryState(query) }, t); } /** Whether a host is connected and reports usage, read without fetching anything. */ diff --git a/packages/app/src/usage/refresh-button.tsx b/packages/app/src/usage/refresh-button.tsx index 48754c4e19..473e4442cc 100644 --- a/packages/app/src/usage/refresh-button.tsx +++ b/packages/app/src/usage/refresh-button.tsx @@ -1,10 +1,10 @@ +import { useTranslation } from "react-i18next"; import { RotateCw } from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { usageCopy } from "./copy"; const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); @@ -14,11 +14,12 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); * header's island, so its highlight is round. */ export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) { + const { t } = useTranslation(); const compact = useIsCompactFormFactor(); const iconSize = paneContentToolbarIconSize(compact); return ( resolvePinnedUsage(reports, preferences), [preferences, reports]); + return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]); } /** Whether the sidebar Usage item has anything to show. */ diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx index 8305c3f878..bf92eaa60f 100644 --- a/packages/app/src/usage/usage-modal.tsx +++ b/packages/app/src/usage/usage-modal.tsx @@ -4,7 +4,6 @@ import { View } from "react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; import { useHostUsageWithControls } from "./controls"; -import { usageCopy } from "./copy"; import { useUsagePreferences } from "./display"; import { useUsageHostSelection } from "./hosts"; import type { UsageHost } from "./model"; @@ -22,11 +21,12 @@ interface UsageModalProps { * title row. */ export function UsageModal(props: UsageModalProps) { + const { t } = useTranslation(); const { serverId, connectedHosts, select } = useUsageHostSelection(); if (!serverId) { return ( - + ); } diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx index ffb40e985d..26699fd447 100644 --- a/packages/app/src/usage/usage-section.tsx +++ b/packages/app/src/usage/usage-section.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { useMemo } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -5,7 +6,6 @@ import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { settingsStyles } from "@/styles/settings"; import { SettingsSection } from "@/components/settings/headings/settings-section"; -import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; import { UsageControls } from "./controls"; import { UsageList } from "./list"; @@ -50,26 +50,32 @@ export function UsageBody({ display: UsageDisplay; onRefresh: () => void; }) { + const { t } = useTranslation(); if (view.kind === "unavailable") { return ; } if (view.kind === "loading") { - return ; + return ; } if (view.kind === "error") { return ( - + ); } if (view.reports.length === 0) { - return ; + return ; } return ; diff --git a/packages/app/src/usage/use-relative-time-tick.ts b/packages/app/src/usage/use-relative-time-tick.ts new file mode 100644 index 0000000000..c21c1398cb --- /dev/null +++ b/packages/app/src/usage/use-relative-time-tick.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from "react"; +import { subscribeToRelativeTimeTick } from "@/utils/relative-time-ticker"; + +/** The current time as a render input, updated while a relative-time label is mounted. */ +export function useRelativeTimeTick({ enabled }: { enabled: boolean }): number { + const [now, setNow] = useState(Date.now); + + useEffect(() => { + if (!enabled) return undefined; + return subscribeToRelativeTimeTick("minute", () => setNow(Date.now())); + }, [enabled]); + return now; +} diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 79ea6b9f2d..99c1eb9f21 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -1,15 +1,21 @@ import { Pin } from "lucide-react-native"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { usageCopy } from "./copy"; -import { formatDisplayPct, formatResetLabel } from "./format"; +import { + formatDisplayPct, + formatProviderUsageLabel, + formatResetLabel, + formatRunsOutLabel, +} from "./format"; import { UsageMeter } from "./meter"; import { displayPercent, usageWindowRowLabel } from "./model"; import type { UsageDisplayAs } from "./preferences"; import { windowTone } from "./tone"; import type { UsageTone, UsageWindow } from "./types"; +import { useRelativeTimeTick } from "./use-relative-time-tick"; // Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength, // so a hover never reads as the selection. Pinned rows do not react to hover. @@ -39,21 +45,24 @@ export function UsageWindowBar({ pinLabel: string; pinTestID: string; }) { + const { t, i18n } = useTranslation(); const shownPct = displayPercent(window, displayAs); const tone = windowTone(window); + const now = useRelativeTimeTick({ enabled: window.runsOutAt != null || window.resetsAt != null }); const isAtRisk = window.runsOutAt != null && window.shortfallPct != null && - (!retained || Date.parse(window.runsOutAt) > Date.now()); + (!retained || Date.parse(window.runsOutAt) > now); const trailing = isAtRisk - ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim() - : formatResetLabel(window.resetsAt, retained); + ? formatRunsOutLabel(window.runsOutAt, now, t) + : formatResetLabel(window.resetsAt, retained, now, t); - const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—"; + const value = + shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—"; const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); const content = { - label: window.label, + label: formatProviderUsageLabel(window.id, window.label, t), value, trailing, isAtRisk, @@ -124,7 +133,7 @@ function WindowRowContent({ {label} - + {value} {trailing ? ( {` · ${trailing}`} @@ -142,6 +151,7 @@ function WindowRowContent({ const ThemedPin = withUnistyles(Pin); function UsagePinGlyph({ pinned }: { pinned: boolean }) { + const { t } = useTranslation(); const iconMapping = useMemo( () => (theme: { colors: { foregroundMuted: string } }) => ({ color: theme.colors.foregroundMuted, @@ -160,7 +170,9 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) { - {pinned ? usageCopy.unpin : usageCopy.pin} + + {pinned ? t("providerUsage.unpin") : t("providerUsage.pin")} + ); @@ -184,14 +196,14 @@ const styles = StyleSheet.create((theme) => ({ display: "none", }, highlightPinned: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, zIndex: -1, borderRadius: theme.borderRadius.md, backgroundColor: theme.colors.surface2, }, // The pinned surface at half strength: a separate layer, so the text keeps full opacity. highlightHovered: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, zIndex: -1, borderRadius: theme.borderRadius.md, backgroundColor: theme.colors.surface2, @@ -209,6 +221,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, value: { + flexShrink: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, diff --git a/packages/app/src/workspace-tabs/explorer-sidebar.test.ts b/packages/app/src/workspace-tabs/explorer-sidebar.test.ts index af72c48194..15fd44f649 100644 --- a/packages/app/src/workspace-tabs/explorer-sidebar.test.ts +++ b/packages/app/src/workspace-tabs/explorer-sidebar.test.ts @@ -23,7 +23,6 @@ import { } from "@/workspace-tabs/explorer-sidebar"; const WORKSPACE_KEY = "server-1:ws-main"; -const CHECKOUT = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; beforeEach(() => { useWorkspaceLayoutStore.setState({ @@ -34,40 +33,35 @@ beforeEach(() => { }); usePanelStore.setState({ mobilePanel: { target: "agent", revision: 0 }, - explorerTab: "files", - explorerTabByCheckout: {}, }); }); describe("Explorer sidebar", () => { - it("selects the Explorer shell from layout and split capabilities", () => { + it("selects the Explorer shell from the form factor", () => { expect(resolveExplorerSidebarPresentation({ isCompact: true })).toBe("overlay"); - expect( - resolveExplorerSidebarPresentation({ isCompact: false, supportsPaneSplits: false }), - ).toBe("dock"); - expect(resolveExplorerSidebarPresentation({ isCompact: false, supportsPaneSplits: true })).toBe( - "pane", - ); + expect(resolveExplorerSidebarPresentation({ isCompact: false })).toBe("dock"); }); - it("uses the compact explorer without creating a desktop pane", () => { + it("uses the same Explorer pane inside the compact overlay", () => { openExplorerSidebarView({ isCompact: true, workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, view: "changes", }); expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); - expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toBeUndefined(); + const state = useWorkspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[WORKSPACE_KEY]; + const pane = findPaneById(layout.root, selectExplorerSidebarPaneId(state, WORKSPACE_KEY)!); + expect( + collectAllTabs(layout.root).find((tab) => tab.tabId === pane?.focusedTabId)?.target.kind, + ).toBe("changes_tree"); }); it("creates a dedicated desktop Explorer containing only its requested tree", () => { openExplorerSidebarView({ isCompact: false, - supportsPaneSplits: true, workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, view: "files", }); @@ -81,9 +75,7 @@ describe("Explorer sidebar", () => { it("toggles the desktop Explorer independently of ordinary panes", () => { const input = { isCompact: false, - supportsPaneSplits: true, workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, }; openExplorerSidebarView({ ...input, view: "files" }); toggleExplorerSidebar(input); @@ -104,16 +96,13 @@ describe("Explorer sidebar", () => { }); it("toggles the compact Explorer without changing its selected view", () => { - usePanelStore.getState().setExplorerTabForCheckout({ ...CHECKOUT, tab: "files" }); const input = { isCompact: true, workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, }; toggleExplorerSidebar(input); expect(isExplorerSidebarOpen(input)).toBe(true); - expect(usePanelStore.getState().explorerTab).toBe("files"); }); }); diff --git a/packages/app/src/workspace-tabs/explorer-sidebar.ts b/packages/app/src/workspace-tabs/explorer-sidebar.ts index ab2d7db716..26c64e016b 100644 --- a/packages/app/src/workspace-tabs/explorer-sidebar.ts +++ b/packages/app/src/workspace-tabs/explorer-sidebar.ts @@ -1,6 +1,4 @@ -import { supportsDesktopPaneSplits } from "@/constants/layout"; import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; -import type { ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { selectIsExplorerSidebarVisible, useWorkspaceLayoutStore, @@ -8,7 +6,7 @@ import { import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; export type ExplorerSidebarView = "changes" | "files" | "pr"; -export type ExplorerSidebarPresentation = "overlay" | "dock" | "pane"; +export type ExplorerSidebarPresentation = "overlay" | "dock"; const VIEW_TARGETS: Record = { changes: { kind: "changes_tree" }, @@ -19,48 +17,24 @@ const VIEW_TARGETS: Record = { export interface ExplorerSidebarQuery { isCompact: boolean; workspaceKey: string | null; - supportsPaneSplits?: boolean; -} - -export interface ExplorerSidebarInput extends ExplorerSidebarQuery { - checkout: ExplorerCheckoutContext | null; } export function resolveExplorerSidebarPresentation( - input: Pick, + input: Pick, ): ExplorerSidebarPresentation { if (input.isCompact) { return "overlay"; } - return (input.supportsPaneSplits ?? supportsDesktopPaneSplits()) ? "pane" : "dock"; -} - -export function usesCompactExplorerSidebar( - input: Pick, -): boolean { - return resolveExplorerSidebarPresentation(input) !== "pane"; -} - -function canUseExplorerSidebar( - input: Pick, -): boolean { - return resolveExplorerSidebarPresentation(input) === "pane"; + return "dock"; } /** Reveals the Explorer sidebar and selects one of its navigation trees. */ export function openExplorerSidebarView( - input: ExplorerSidebarInput & { view: ExplorerSidebarView }, + input: ExplorerSidebarQuery & { view: ExplorerSidebarView }, ): void { - if (usesCompactExplorerSidebar(input)) { - if (!input.checkout) return; - const panel = usePanelStore.getState(); - panel.setExplorerTabForCheckout({ ...input.checkout, tab: input.view }); - panel.openCompactFileExplorer(input.checkout); - return; - } if (!input.workspaceKey) return; const store = useWorkspaceLayoutStore.getState(); - const paneId = store.showExplorerSidebar(input.workspaceKey); + const paneId = showExplorerSidebar(input); store.openTab({ workspaceKey: input.workspaceKey, target: VIEW_TARGETS[input.view], @@ -69,29 +43,33 @@ export function openExplorerSidebarView( }); } -export function showExplorerSidebar(input: ExplorerSidebarInput): void { - if (usesCompactExplorerSidebar(input)) { - if (input.checkout) usePanelStore.getState().openCompactFileExplorer(input.checkout); - return; +export function showExplorerSidebar(input: ExplorerSidebarQuery): string | null { + if (input.isCompact) { + usePanelStore.getState().showMobileFileExplorer(); } - if (input.workspaceKey && canUseExplorerSidebar(input)) { - useWorkspaceLayoutStore.getState().showExplorerSidebar(input.workspaceKey); + if (input.workspaceKey) { + const store = useWorkspaceLayoutStore.getState(); + return input.isCompact + ? store.ensureExplorerSidebar(input.workspaceKey) + : store.showExplorerSidebar(input.workspaceKey); } + return null; } -export function hideExplorerSidebar(input: ExplorerSidebarInput): void { - if (usesCompactExplorerSidebar(input)) { +export function hideExplorerSidebar(input: ExplorerSidebarQuery): void { + if (input.isCompact) { usePanelStore.getState().showMobileAgent(); return; } - if (input.workspaceKey && canUseExplorerSidebar(input)) { + if (input.workspaceKey) { useWorkspaceLayoutStore.getState().hideExplorerSidebar(input.workspaceKey); } } -export function toggleExplorerSidebar(input: ExplorerSidebarInput): void { - if (usesCompactExplorerSidebar(input)) { - if (input.checkout) usePanelStore.getState().toggleCompactFileExplorer(input.checkout); +export function toggleExplorerSidebar(input: ExplorerSidebarQuery): void { + if (input.isCompact) { + if (isExplorerSidebarOpen(input)) hideExplorerSidebar(input); + else showExplorerSidebar(input); return; } if (!input.workspaceKey) return; @@ -105,20 +83,17 @@ export function toggleExplorerSidebar(input: ExplorerSidebarInput): void { export function useIsExplorerSidebarOpen(input: ExplorerSidebarQuery): boolean { const compactOpen = usePanelStore(selectIsCompactFileExplorerOpen); const paneOpen = useWorkspaceLayoutStore((state) => - input.workspaceKey && canUseExplorerSidebar(input) - ? selectIsExplorerSidebarVisible(state, input.workspaceKey) - : false, + input.workspaceKey ? selectIsExplorerSidebarVisible(state, input.workspaceKey) : false, ); - return usesCompactExplorerSidebar(input) ? compactOpen : paneOpen; + return input.isCompact ? compactOpen : paneOpen; } export function isExplorerSidebarOpen(input: ExplorerSidebarQuery): boolean { - if (usesCompactExplorerSidebar(input)) { + if (input.isCompact) { return selectIsCompactFileExplorerOpen(usePanelStore.getState()); } return Boolean( input.workspaceKey && - canUseExplorerSidebar(input) && selectIsExplorerSidebarVisible(useWorkspaceLayoutStore.getState(), input.workspaceKey), ); } diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx index e3e94977b7..8da5a563ee 100644 --- a/packages/app/src/workspace-tabs/launcher/index.tsx +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next"; import { Globe, SquarePen, SquareTerminal } from "lucide-react-native"; import invariant from "tiny-invariant"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; -import { resolvePluginIcon } from "@/plugins/icons"; +import { pluginIconComponent } from "@/plugins/icons"; import { useInstalledPlugins } from "@/plugins/registry"; import { pluginPanelSupportsLocation } from "@/plugins/workspace-panels/locations"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; @@ -120,7 +120,6 @@ export function useWorkspaceTabLaunchCatalog(input: { }, [router, serverId]); return useMemo(() => { - const isExplorerMenu = host === "explorer" && surface === "menu"; const changesPresentation = getLaunchPresentation("changes_tree"); const diffPresentation = getLaunchPresentation("working_diff"); const filesPresentation = getLaunchPresentation("files"); @@ -133,7 +132,6 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-agent", disabled: false, panelKind: "draft", - hidden: isExplorerMenu, launch: launchSelection(BUILT_IN_SELECTIONS.agent), }, terminal: { @@ -212,7 +210,7 @@ export function useWorkspaceTabLaunchCatalog(input: { pluginItems.push({ id: `plugin:${plugin.id}:${panel.id}`, label: plugin.i18n.resolve(panel.title, plugin.locale), - Icon: resolvePluginIcon(panel.icon), + Icon: pluginIconComponent(panel.icon), disabled: false, panelKind: "plugin", launch: launchSelection(selection), @@ -225,7 +223,7 @@ export function useWorkspaceTabLaunchCatalog(input: { if (pluginItems.length > 0) { groups.push({ id: "plugin-panels", label: null, items: pluginItems }); } - if (!isExplorerMenu && profiles.length > 0) { + if (profiles.length > 0) { groups.push({ id: "terminal-profiles", label: t("workspace.tabs.actions.terminalProfilesMenu"), diff --git a/packages/app/src/workspace-tabs/navigation.test.ts b/packages/app/src/workspace-tabs/navigation.test.ts new file mode 100644 index 0000000000..ab3d1539ab --- /dev/null +++ b/packages/app/src/workspace-tabs/navigation.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, expect, it } from "vitest"; +import { + selectIsExplorerSidebarVisible, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; +import { usePanelStore } from "@/stores/panel-store"; +import { revealWorkspaceTab } from "./navigation"; +import { openWorkspaceTargetAtLocation } from "./open-beside"; + +const workspaceKey = "server:workspace"; +beforeEach(() => { + useWorkspaceLayoutStore.setState({ + layoutMode: "split-panes", + layoutByWorkspace: {}, + sheetsByWorkspace: {}, + explorerSidebarPaneIdByWorkspace: {}, + }); + usePanelStore.getState().showMobileFileExplorer(); +}); +it("reveals an existing main file selected from compact Explorer", () => { + const store = useWorkspaceLayoutStore.getState(); + const tabId = store.openTab({ + workspaceKey, + target: { kind: "file", path: "existing.ts" }, + intent: "reveal", + }); + store.setLayoutMode("compact"); + const existing = openWorkspaceTargetAtLocation({ + workspaceKey, + target: { kind: "file", path: "existing.ts" }, + location: "main", + }); + expect(existing).toBe(tabId); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); +}); +it("keeps Explorer beneath a new compact file sheet", () => { + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode("compact"); + const tabId = openWorkspaceTargetAtLocation({ + workspaceKey, + target: { kind: "file", path: "new.ts" }, + location: "main", + }); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + expect(useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]?.[0].tab.tabId).toBe( + tabId, + ); +}); +it("reveals an explicit Explorer target and preserves the surface after a failed open", () => { + const store = useWorkspaceLayoutStore.getState(); + const paneId = store.showExplorerSidebar(workspaceKey)!; + store.setLayoutMode("compact"); + usePanelStore.getState().showMobileAgent(); + const tabId = store.openTab({ + workspaceKey, + target: { kind: "file", path: "draft.ts" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }); + revealWorkspaceTab(workspaceKey, tabId); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + revealWorkspaceTab(workspaceKey, null); + revealWorkspaceTab(workspaceKey, "missing"); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); +}); +it("preserves dock visibility intent while compact targets are opened and revealed", () => { + const store = useWorkspaceLayoutStore.getState(); + const paneId = store.showExplorerSidebar(workspaceKey)!; + store.hideExplorerSidebar(workspaceKey); + store.setLayoutMode("compact"); + store.showExplorerSidebar(workspaceKey); + const tabId = store.openTab({ + workspaceKey, + target: { kind: "file", path: "overlay-only.ts" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }); + revealWorkspaceTab(workspaceKey, tabId); + store.setLayoutMode("split-panes"); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).toBeDefined(); + expect(useWorkspaceLayoutStore.getState().getTabDestination(workspaceKey, tabId!)).toMatchObject({ + host: "explorer", + }); + expect(selectIsExplorerSidebarVisible(useWorkspaceLayoutStore.getState(), workspaceKey)).toBe( + false, + ); +}); diff --git a/packages/app/src/workspace-tabs/navigation.ts b/packages/app/src/workspace-tabs/navigation.ts new file mode 100644 index 0000000000..5e36f65102 --- /dev/null +++ b/packages/app/src/workspace-tabs/navigation.ts @@ -0,0 +1,19 @@ +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { usePanelStore } from "@/stores/panel-store"; + +/** Reveals the actual destination after placement and deduplication, including existing tabs. */ +export function revealWorkspaceTab(workspaceKey: string, tabId: string | null): string | null { + if (!tabId) return null; + const store = useWorkspaceLayoutStore.getState(); + const destination = store.getTabDestination(workspaceKey, tabId); + // Opening a sheet already reveals it, preserving the surface underneath. + if (!destination) return null; + if (destination.kind === "sheet") return tabId; + store.focusTab(workspaceKey, tabId); + if (store.layoutMode === "compact") { + const panels = usePanelStore.getState(); + if (destination.host === "explorer") panels.showMobileFileExplorer(); + else panels.showMobileAgent(); + } + return tabId; +} diff --git a/packages/app/src/workspace-tabs/open-beside.ts b/packages/app/src/workspace-tabs/open-beside.ts index 0c0c9ebdf5..abf6eba2ec 100644 --- a/packages/app/src/workspace-tabs/open-beside.ts +++ b/packages/app/src/workspace-tabs/open-beside.ts @@ -1,3 +1,4 @@ +import { revealWorkspaceTab } from "./navigation"; import type { OpenInSidePanePreferences } from "@/hooks/use-settings"; import { collectAllTabs, @@ -117,13 +118,16 @@ export function openWorkspaceTargetAtLocation( const parentPane = findPaneContainingTab(layout.root, input.parentTabId); placement = parentPane ? { mode: "prefer", paneId: parentPane.id } : undefined; } - return store.openTab({ - workspaceKey: input.workspaceKey, - target: input.target, - intent: "reveal", - placement, - parentTabId: input.parentTabId ?? undefined, - }); + return revealWorkspaceTab( + input.workspaceKey, + store.openTab({ + workspaceKey: input.workspaceKey, + target: input.target, + intent: "reveal", + placement, + parentTabId: input.parentTabId ?? undefined, + }), + ); } /** Opens a tree selection while reusing an unmodified preview in its destination pane. */ @@ -170,15 +174,21 @@ export function openPreferredWorkspacePreview( nextTarget: input.target, }) ) { - return store.replaceTab(input.workspaceKey, activeTab.tabId, input.target); + return revealWorkspaceTab( + input.workspaceKey, + store.replaceTab(input.workspaceKey, activeTab.tabId, input.target), + ); } - return store.openTab({ - workspaceKey: input.workspaceKey, - target: input.target, - intent: "reveal", - placement: { mode: "pane", paneId: destinationPaneId }, - parentTabId: input.parentTabId ?? activeTab?.tabId ?? undefined, - }); + return revealWorkspaceTab( + input.workspaceKey, + store.openTab({ + workspaceKey: input.workspaceKey, + target: input.target, + intent: "reveal", + placement: { mode: "pane", paneId: destinationPaneId }, + parentTabId: input.parentTabId ?? activeTab?.tabId ?? undefined, + }), + ); } /** @@ -192,11 +202,14 @@ export function openWorkspaceTargetBeside(input: OpenWorkspaceTargetInput): stri } const store = useWorkspaceLayoutStore.getState(); const paneId = store.ensureSidePane(input.workspaceKey); - return store.openTab({ - workspaceKey: input.workspaceKey, - target: input.target, - intent: "reveal", - placement: paneId ? { mode: "pane", paneId } : undefined, - parentTabId: input.parentTabId ?? undefined, - }); + return revealWorkspaceTab( + input.workspaceKey, + store.openTab({ + workspaceKey: input.workspaceKey, + target: input.target, + intent: "reveal", + placement: paneId ? { mode: "pane", paneId } : undefined, + parentTabId: input.parentTabId ?? undefined, + }), + ); } diff --git a/packages/app/src/workspace-tabs/open-supporting-view.test.ts b/packages/app/src/workspace-tabs/open-supporting-view.test.ts index b3e6f4c976..0e423b02c3 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.test.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.test.ts @@ -13,6 +13,7 @@ import { usePanelStore } from "@/stores/panel-store"; import { collectAllTabs, findPaneById, + selectExplorerSidebarPaneId, useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; import { @@ -23,15 +24,22 @@ import { } from "@/workspace-tabs/open-supporting-view"; const WORKSPACE_KEY = "server-1:workspace-1"; -const CHECKOUT = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; + +function activeExplorerKind() { + const state = useWorkspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[WORKSPACE_KEY]; + const paneId = selectExplorerSidebarPaneId(state, WORKSPACE_KEY); + const pane = paneId ? findPaneById(layout.root, paneId) : null; + return collectAllTabs(layout.root).find((tab) => tab.tabId === pane?.focusedTabId)?.target.kind; +} beforeEach(() => { usePanelStore.setState({ mobilePanel: { target: "agent", revision: 0 }, - explorerTab: "files", - explorerTabByCheckout: {}, }); useWorkspaceLayoutStore.setState({ + layoutMode: "split-panes", + sheetsByWorkspace: {}, pullRequestTabAutoOpenedByWorkspace: {}, layoutByWorkspace: {}, explorerSidebarPaneIdByWorkspace: {}, @@ -42,15 +50,15 @@ beforeEach(() => { describe("openWorkspaceChanges", () => { it("opens Changes in the compact Explorer", () => { + useWorkspaceLayoutStore.getState().setLayoutMode("compact"); openWorkspaceChanges({ isCompact: true, workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, preferences: DEFAULT_APP_SETTINGS.openInSidePane, }); expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); - expect(usePanelStore.getState().explorerTab).toBe("changes"); + expect(activeExplorerKind()).toBe("changes_tree"); }); }); @@ -59,7 +67,6 @@ describe("openComposerChanges", () => { isCompact: false, supportsPaneSplits: true, workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, preferences: DEFAULT_APP_SETTINGS.openInSidePane, }; @@ -105,12 +112,12 @@ describe("openComposerChanges", () => { }); it("keeps opening the compact Explorer on Changes", () => { + useWorkspaceLayoutStore.getState().setLayoutMode("compact"); openComposerChanges({ ...input, isCompact: true }); openComposerChanges({ ...input, isCompact: true }); expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); - expect(usePanelStore.getState().explorerTab).toBe("changes"); - expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toBeUndefined(); + expect(activeExplorerKind()).toBe("changes_tree"); }); }); @@ -119,17 +126,16 @@ describe("openWorkspacePullRequest", () => { isCompact: false, supportsPaneSplits: true, workspaceKey: WORKSPACE_KEY, - checkout: CHECKOUT, }; it.each(["main", "side", "explorer"] as const)( "opens the compact PR view in Explorer when the desktop preference is %s", (destination) => { + useWorkspaceLayoutStore.getState().setLayoutMode("compact"); openWorkspacePullRequest({ ...input, isCompact: true, destination }); expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); - expect(usePanelStore.getState().explorerTab).toBe("pr"); - expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toBeUndefined(); + expect(activeExplorerKind()).toBe("pull_request"); }, ); diff --git a/packages/app/src/workspace-tabs/open-supporting-view.ts b/packages/app/src/workspace-tabs/open-supporting-view.ts index bb567d4bd0..73ae55c31f 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.ts @@ -1,3 +1,5 @@ +import { supportsDesktopPaneSplits } from "@/constants/layout"; +import { revealWorkspaceTab } from "./navigation"; import { collectAllPanes, collectAllTabs, @@ -8,11 +10,9 @@ import { useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; import type { OpenInSidePanePreferences, PullRequestOpenLocation } from "@/hooks/use-settings"; -import type { ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { isExplorerSidebarOpen, openExplorerSidebarView, - usesCompactExplorerSidebar, type ExplorerSidebarView, } from "@/workspace-tabs/explorer-sidebar"; import { @@ -23,7 +23,6 @@ import { interface WorkspaceViewInput { isCompact: boolean; workspaceKey: string | null; - checkout: ExplorerCheckoutContext | null; supportsPaneSplits?: boolean; } @@ -35,13 +34,17 @@ interface OpenWorkspacePullRequestInput extends WorkspaceViewInput { destination: PullRequestOpenLocation; } +function supportingViewsBelongInExplorer(input: WorkspaceViewInput): boolean { + return input.isCompact || !(input.supportsPaneSplits ?? supportsDesktopPaneSplits()); +} + function openExplorerView(input: WorkspaceViewInput, view: ExplorerSidebarView): void { openExplorerSidebarView({ ...input, view }); } /** Opens the workspace Changes view according to the current layout and diff preference. */ export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string | null { - if (usesCompactExplorerSidebar(input)) { + if (supportingViewsBelongInExplorer(input)) { openExplorerView(input, "changes"); return null; } @@ -55,7 +58,7 @@ export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string | /** Reveals Changes from the composer, then opens its diff on a subsequent desktop action. */ export function openComposerChanges(input: OpenWorkspaceChangesInput): string | null { - if (usesCompactExplorerSidebar(input) || isExplorerSidebarOpen(input)) { + if (supportingViewsBelongInExplorer(input) || isExplorerSidebarOpen(input)) { return openWorkspaceChanges(input); } openExplorerView(input, "changes"); @@ -64,15 +67,26 @@ export function openComposerChanges(input: OpenWorkspaceChangesInput): string | /** Opens the workspace pull request at its semantic destination. */ export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput): string | null { - if (usesCompactExplorerSidebar(input) || input.destination === "explorer") { - openExplorerView(input, "pr"); - return null; + if (!input.workspaceKey) return null; + let tabId: string | null; + if (supportingViewsBelongInExplorer(input) || input.destination === "explorer") { + const store = useWorkspaceLayoutStore.getState(); + const paneId = store.ensureExplorerSidebar(input.workspaceKey); + tabId = store.openTab({ + workspaceKey: input.workspaceKey, + target: { kind: "pull_request" }, + intent: "reveal", + placement: paneId ? { mode: "prefer", paneId } : undefined, + }); + } else { + return openWorkspaceTargetAtLocation({ + workspaceKey: input.workspaceKey, + target: { kind: "pull_request" }, + location: input.destination, + }); } - return openWorkspaceTargetAtLocation({ - workspaceKey: input.workspaceKey, - target: { kind: "pull_request" }, - location: input.destination, - }); + revealWorkspaceTab(input.workspaceKey, tabId); + return tabId; } /** Adds a detected desktop PR once without revealing or selecting its destination. */ diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index e2d67aca70..35272f336e 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -2,6 +2,8 @@ import { defineConfig, configDefaults } from "vitest/config"; import { playwright } from "@vitest/browser-playwright"; import path from "path"; import fs from "fs"; +import { transformSync } from "@babel/core"; +import { build } from "esbuild"; // Set this before Vite resolves dependencies and Vitest starts its workers. Object.assign(process.env, { NODE_ENV: "test" }); @@ -16,6 +18,43 @@ const resolvePackageEntry = (packageName: string) => { }; export default defineConfig({ + plugins: [ + { + name: "compile-usage-components", + enforce: "pre", + transform(source, id) { + // These labels must react to time and language in the compiled app too. + if (!id.includes("/src/usage/") || !id.endsWith(".tsx") || id.includes(".test.")) return; + const result = transformSync(source, { + filename: id, + babelrc: false, + configFile: false, + parserOpts: { plugins: ["typescript", "jsx"] }, + plugins: [["babel-plugin-react-compiler", { target: "19" }]], + sourceMaps: true, + }); + if (result?.code) return { code: result.code, map: result.map }; + }, + }, + { + name: "bundle-i18n-resources", + // Metro resolves literal requires lazily. Vite's TS transform leaves them + // to Node, which cannot resolve the resource modules. Bundle this boundary + // so unit and browser consumers exercise the same deferred module loading. + async transform(_source, id) { + if (path.resolve(id) !== path.resolve(__dirname, "src/i18n/i18next.ts")) return; + const result = await build({ + entryPoints: [id], + bundle: true, + format: "esm", + platform: "neutral", + external: ["i18next", "react-i18next"], + write: false, + }); + return { code: result.outputFiles[0].text, map: null }; + }, + }, + ], test: { environment: "node", exclude: [...configDefaults.exclude, "e2e/**"], @@ -68,6 +107,8 @@ export default defineConfig({ "zustand", "@tanstack/react-query", "react-native-web", + "react-native-reanimated", + "react-native-worklets", "react-native-gesture-handler", "react-native-keyboard-controller", ], @@ -82,6 +123,7 @@ export default defineConfig({ // Bundle the CJS dependencies of the excluded gesture-handler package for the browser. include: [ "react/jsx-runtime", + "react/compiler-runtime", "react-native-gesture-handler > hoist-non-react-statics", "react-native-gesture-handler > invariant", ], diff --git a/packages/cli/package.json b/packages/cli/package.json index 8c7d796963..a4bacd86fa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.12.0-beta.3", + "version": "0.12.0", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.12.0-beta.3", - "@getpaseo/protocol": "0.12.0-beta.3", - "@getpaseo/server": "0.12.0-beta.3", + "@getpaseo/client": "0.12.0", + "@getpaseo/protocol": "0.12.0", + "@getpaseo/server": "0.12.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index a042d0cd8c..07ba43b332 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.12.0-beta.3", + "version": "0.12.0", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.12.0-beta.3", - "@getpaseo/relay": "0.12.0-beta.3", + "@getpaseo/protocol": "0.12.0", + "@getpaseo/relay": "0.12.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index d939b3c72b..eda2fec26b 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -141,6 +141,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = { [CLIENT_CAPS.timelineNotifications]: true, [CLIENT_CAPS.pluginTimelineItems]: true, [CLIENT_CAPS.workspaceSetupBlocked]: true, + [CLIENT_CAPS.workspaceSetupObservation]: true, [CLIENT_CAPS.explicitEventSubscriptions]: true, } satisfies Record, true>; diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 6b82922841..7469470ad1 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1038,6 +1038,7 @@ test("advertises client capabilities in hello", async () => { timeline_notifications: true, plugin_timeline_items: true, workspace_setup_blocked: true, + workspace_setup_observation: true, hello_rejection: true, browser_host: { supportedCommands: ["list_tabs"], diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index e0e4d9ddc5..647e1fddf6 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -12,7 +12,7 @@ import { CreationClient } from "./creation/index.js"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; -import type { ClientCapability } from "@getpaseo/protocol/client-capabilities"; +import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { @@ -2313,6 +2313,8 @@ export class DaemonClient { message, async (query, accept) => { resetSource = false; + if (query.type === "workspace.setup.subscribe.request") + this.requireWorkspaceSetupObservation(); const requestId = this.createRequestId(options?.requestId); const request = SessionInboundMessageSchema.parse({ ...query, requestId }); try { @@ -2781,6 +2783,27 @@ export class DaemonClient { }); } + observeWorkspaceSetup( + workspaceId: string, + options?: { signal?: AbortSignal }, + ): OwnedSubscription> { + return this.observe( + "workspace.setup.subscribe.response", + { type: "workspace.setup.subscribe.request", workspaceId }, + options, + ); + } + + private requireWorkspaceSetupObservation(): void { + if ( + this.lastServerInfoMessage?.features?.workspaceSetupObservation !== true || + this.lastServerInfoMessage.features.ownedSubscriptions !== true || + this.config.capabilities?.[CLIENT_CAPS.workspaceSetupObservation] === false || + this.config.capabilities?.[CLIENT_CAPS.ownedSubscriptions] === false + ) + throw new Error("Update the host to view workspace setup details"); + } + async fetchWorkspaceSetupStatus( workspaceId: string, requestId?: string, diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index fb0958aad1..af142e6385 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url"; import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { chromium } from "playwright"; +import { installDeveloperToastDismissal } from "../../app/e2e/support/developer-ui.mjs"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs"; @@ -1173,6 +1174,7 @@ async function main() { cwd: rootDir, env: { ...commonEnv, + EXPO_UNSTABLE_HEADLESS: "1", EXPO_PORT: String(expoPort), EXPO_DEV_URL: `http://localhost:${expoPort}`, PASEO_ELECTRON_REMOTE_DEBUGGING_PORT: String(cdpPort), @@ -1187,6 +1189,7 @@ async function main() { browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); const page = await waitForAppPage(browser, expoPort); + await installDeveloperToastDismissal(page); const status = await waitForDesktopStatus(page); const checkPluginLinks = () => diff --git a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs index d32b7d65b8..a98a158511 100644 --- a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs +++ b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs @@ -35,6 +35,7 @@ export async function verifyAttachedDaemonControls({ repo, root, env, home, port env: { ...env, EXPO_NO_DOTENV: "1", + EXPO_UNSTABLE_HEADLESS: "1", CI: "1", PASEO_WEB_PLATFORM: "electron", EXPO_PUBLIC_LOCAL_DAEMON: `127.0.0.1:${port}`, diff --git a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts index b7063b4565..d745f983be 100644 --- a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts +++ b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts @@ -240,6 +240,8 @@ async function openTwoDraftTabs(page: Page, workspaceId: string) { async function expectTabNumberNavigation(page: Page) { const tabs = page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }); + await expect(page.getByTestId("settings-sidebar")).not.toBeVisible(); + await expect(tabs.nth(1)).toHaveAttribute("aria-selected", "true"); await page.keyboard.press("Control+1"); await expect(tabs.nth(0)).toHaveAttribute("aria-selected", "true"); await page.keyboard.press("Control+2"); @@ -261,3 +263,35 @@ test("rebinding Jump to tab preserves the number group after reload", async ({ await openSettingsSection(page, "shortcuts"); await expectTabNumberRange(page); }); + +async function expectNumberRequiredBeforeSaving(page: Page, label: string) { + const row = page.getByText(label, { exact: true }).locator(".."); + await expect(row.getByRole("button", { name: "Done", exact: true })).toBeDisabled(); + await expect(row.getByText("End with a number from 1 to 9.", { exact: true })).toBeVisible(); +} + +async function completeNumberShortcut(page: Page, label: string) { + const row = page.getByText(label, { exact: true }).locator(".."); + await page.keyboard.press("Backspace"); + await page.keyboard.press("Control+2"); + await expect(row.getByRole("button", { name: "Done", exact: true })).toBeEnabled(); + await row.getByRole("button", { name: "Done", exact: true }).click(); + await expect(row.getByText("⌃K", { exact: true })).toBeVisible(); + await expect(row.getByText("⌃1-9", { exact: true })).toBeVisible(); +} + +for (const { label, action } of [ + { label: "Jump to tab", action: "workspace-tab-jump-index" }, + { label: "Jump to workspace", action: "workspace-jump-index" }, +]) { + test(`${label} requires a final number before a recorded shortcut can be saved`, async ({ + page, + }) => { + await openShortcutsSettings(page); + await captureShortcut(page, label, action, "Control+K"); + await expectNumberRequiredBeforeSaving(page, label); + await page.keyboard.press("Control+0"); + await expectNumberRequiredBeforeSaving(page, label); + await completeNumberShortcut(page, label); + }); +} diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 60ea32fe5c..1c8cc7687e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.12.0-beta.3", + "version": "0.12.0", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/ios/ExpoTwoWayAudioModule.swift b/packages/expo-two-way-audio/ios/ExpoTwoWayAudioModule.swift index 046f856747..b18afd7deb 100644 --- a/packages/expo-two-way-audio/ios/ExpoTwoWayAudioModule.swift +++ b/packages/expo-two-way-audio/ios/ExpoTwoWayAudioModule.swift @@ -152,7 +152,7 @@ public class ExpoTwoWayAudioModule: Module { EXPermissionsMethodsDelegate.getPermissionWithPermissionsManager( self.appContext?.permissions, withRequester: MicrophonePermissionRequester.self, - resolve: promise.resolver, + resolve: promise.legacyResolver, reject: promise.legacyRejecter ) } @@ -161,7 +161,7 @@ public class ExpoTwoWayAudioModule: Module { EXPermissionsMethodsDelegate.askForPermission( withPermissionsManager: self.appContext?.permissions, withRequester: MicrophonePermissionRequester.self, - resolve: promise.resolver, + resolve: promise.legacyResolver, reject: promise.legacyRejecter ) } diff --git a/packages/expo-two-way-audio/ios/MicrophonePermissionRequester.swift b/packages/expo-two-way-audio/ios/MicrophonePermissionRequester.swift index fb81f461c1..14dc7a2ee5 100644 --- a/packages/expo-two-way-audio/ios/MicrophonePermissionRequester.swift +++ b/packages/expo-two-way-audio/ios/MicrophonePermissionRequester.swift @@ -15,11 +15,11 @@ class MicrophonePermissionRequester: NSObject, EXPermissionsRequester { var status: EXPermissionStatus guard (Bundle.main.infoDictionary?["NSMicrophoneUsageDescription"]) != nil else { - EXFatal(EXErrorWithMessage(""" + log.error(""" This app is missing NSMicrophoneUsageDescription, so audio services will fail. Add one of these keys to your bundle's Info.plist. - """)) - return ["status": EXPermissionStatusDenied] + """) + return ["status": EXPermissionStatusDenied.rawValue] } switch systemStatus { diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index ca03114da2..83006f32a2 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.12.0-beta.3", + "version": "0.12.0", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 9da2494d13..696325f4f7 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.12.0-beta.3", + "version": "0.12.0", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2ff6be23c4..166b772e66 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.12.0-beta.3", + "version": "0.12.0", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -71,20 +71,20 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.12.0-beta.3", - "@getpaseo/protocol": "0.12.0-beta.3", + "@getpaseo/client": "0.12.0", + "@getpaseo/protocol": "0.12.0", "@types/node": "^20.9.0", "@types/react": "~19.2.0", - "react": "19.1.0", - "react-native": "0.81.5", + "react": "19.2.3", + "react-native": "0.86.3", "typescript": "^5.9.3", "vitest": "^4.1.6", "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.12.0-beta.3", - "@getpaseo/protocol": "0.12.0-beta.3", - "react": "~19.1.0", + "@getpaseo/client": "0.12.0", + "@getpaseo/protocol": "0.12.0", + "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" }, diff --git a/packages/plugin/src/client/buttons.ts b/packages/plugin/src/client/buttons.ts index d79e555851..2865a117d6 100644 --- a/packages/plugin/src/client/buttons.ts +++ b/packages/plugin/src/client/buttons.ts @@ -1,14 +1,17 @@ import type { PluginText } from "../text.js"; import type { ComponentType } from "react"; -import type { PluginHostProps } from "./contracts.js"; +import type { PluginIconProps } from "../icons.js"; +import type { PluginHostProps, PluginIcon } from "./contracts.js"; export type PluginButtonContext = | { context: "workspace"; workspaceId: string } | { context: "agent"; workspaceId: string; agentId: string }; -export type PluginButtonIconProps = PluginHostProps & - PluginButtonContext & { size: number; color: string }; + +export type PluginButtonIconProps = PluginHostProps & PluginButtonContext & PluginIconProps; + export type PluginButtonContentProps = PluginHostProps & PluginButtonContext & { close(): void }; -export type PluginButtonIcon = string | ComponentType; + +export type PluginButtonIcon = PluginIcon; export type PluginButtonBehavior = | { kind: "action"; onPress(): void | Promise } diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 5ec7bf6f27..3c4841a13f 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -18,6 +18,7 @@ import type { PluginTimelineTransformResult, PluginCleanup, } from "../contracts.js"; +import type { PluginIconProps, PluginSerializableIcon } from "../icons.js"; export interface PluginHostProps { theme: PluginTheme; @@ -88,11 +89,13 @@ export interface PluginSidebarItemContribution Component: ComponentType; } -export interface PluginIconProps { - name: string; - size?: number; - color?: string; -} +/** + * A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size + * and color; buttons widen the props with their host and target context. + */ +export type PluginIcon = + | PluginSerializableIcon + | ComponentType; export type PluginPanelLocation = "workspace" | "explorer"; @@ -103,7 +106,7 @@ export interface PluginOpenPanelOptions { interface PluginWorkspacePanelBase { id: string; title: Text; - icon: string; + icon: PluginIcon; locations?: readonly PluginPanelLocation[]; } @@ -176,7 +179,7 @@ export type PluginWorkspacePanelContribution = export interface PluginSettingsScreenContribution { id: string; title: Text; - icon: string; + icon: PluginIcon; Component: ComponentType; } @@ -200,7 +203,7 @@ export interface PluginSurfaceContribution { export interface PluginSidebarContribution { id: string; title: Text; - icon: string; + icon: PluginIcon; surface: string; } @@ -269,7 +272,7 @@ export interface PluginAgentCommandContext extends PluginCommandCapabilities { interface PluginCommandCenterItemBase { id: string; title: Text; - icon: string; + icon: PluginIcon; keywords?: readonly string[]; } diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index 8879b14a8b..c75aeeae12 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -8,7 +8,7 @@ export type { PluginScreenLocation, PluginSidebarItemProps, PluginSidebarItemContribution, - PluginIconProps, + PluginIcon, PluginPanelLocation, PluginOpenPanelOptions, PluginWorkspacePanelProps, @@ -45,6 +45,7 @@ export type { PluginComposerPillContribution, PluginHeaderButtonContribution, } from "./buttons.js"; +export type { PluginIconProps, PluginSerializableIcon, PluginSvgIcon } from "../icons.js"; export { usePaseo } from "./paseo-context.js"; export { useAgent, useWorkspace } from "./client-state.js"; export { useRpc } from "./rpc-context.js"; diff --git a/packages/plugin/src/client/react-native.ts b/packages/plugin/src/client/react-native.ts index ea196c433f..f5862b31d1 100644 --- a/packages/plugin/src/client/react-native.ts +++ b/packages/plugin/src/client/react-native.ts @@ -17,7 +17,8 @@ import type { TextInput as NativeTextInput, TextInputProps, } from "react-native"; -import type { PluginIconProps } from "./contracts.js"; +import type { PluginIconProps } from "../icons.js"; +import type { PluginIcon } from "./contracts.js"; export interface ModalProps { title: string; @@ -53,12 +54,16 @@ export interface ToastApi { error(message: string): void; } -export declare const Icon: ComponentType; +/** Renders a Lucide icon by `name`, or any plugin icon by `icon`. An unusable icon renders nothing. */ +export type IconProps = Partial & + ({ name: string; icon?: never } | { icon: PluginIcon; name?: never }); + +export declare const Icon: ComponentType; export declare const Modal: ModalComponent; export declare function useToast(): ToastApi; export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string; -export type { PluginIconProps } from "./contracts.js"; +export type { PluginIconProps } from "../icons.js"; /** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */ export declare const ScrollView: ForwardRefExoticComponent< diff --git a/packages/plugin/src/client/ui.ts b/packages/plugin/src/client/ui.ts index 6686d8292b..8d5e3ff686 100644 --- a/packages/plugin/src/client/ui.ts +++ b/packages/plugin/src/client/ui.ts @@ -1,4 +1,5 @@ import type { ComponentType, ReactNode, Ref } from "react"; +import type { PluginIcon } from "./contracts.js"; export interface SettingsSectionProps { title: string; @@ -64,14 +65,14 @@ export interface ExternalLinkProps { } export declare const ExternalLink: ComponentType; -export type SidebarIcon = string | ComponentType<{ size: number; color: string }>; +export type SidebarIcon = PluginIcon; export interface SidebarRowProps { /** * Tells rows of one item apart when the item renders several: "bot-2". Unique within the item. * Omit it when the item renders one row. */ id?: string; - /** A Lucide icon name or a component. */ + /** A Lucide icon name, inline SVG, or a component. */ icon?: SidebarIcon; /** Defaults to the item's registered title. */ label?: string; diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index e44e09c9e7..7e71cb22c5 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -1,5 +1,6 @@ import type { PluginText } from "./text.js"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import type { PluginSerializableIcon } from "./icons.js"; import type { PluginRpcContract } from "./rpc.js"; export interface PluginTheme { @@ -74,7 +75,7 @@ export interface PluginThemeContribution { export interface PluginAttachmentSourceContribution { id: string; title: Text; - icon: string; + icon: PluginSerializableIcon; pickerTitle: Text; searchPlaceholder: Text; search: PluginRpcContract; diff --git a/packages/plugin/src/icons.test.ts b/packages/plugin/src/icons.test.ts new file mode 100644 index 0000000000..fd8ddbc9fb --- /dev/null +++ b/packages/plugin/src/icons.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { findSvgIconProblem } from "./icons.js"; + +describe("findSvgIconProblem", () => { + it.each([ + ["a plain SVG", ''], + ["leading whitespace", '\n '], + [ + "a fragment reference", + '', + ], + ["currentColor fills", ''], + ])("accepts %s", (_name, svg) => { + expect(findSvgIconProblem(svg)).toBeNull(); + }); + + it.each([ + ["a non-SVG document", "hello", "not an SVG document"], + ["an element", "", "not an SVG document"], + ["a script", "", "script elements are not allowed"], + ["a foreign object", "", "foreignObject elements are not allowed"], + ["a style", "", "style elements are not allowed"], + [ + "an event handler", + '', + "event-handler attributes are not allowed", + ], + [ + "a javascript URL", + '', + "javascript URLs are not allowed", + ], + [ + "an external href", + '', + "external href references are not allowed", + ], + [ + "an external xlink:href", + "", + "external href references are not allowed", + ], + ["markup over 64 KiB", `${" ".repeat(64 * 1024)}`, "exceeds 64 KiB"], + ])("rejects %s", (_name, svg, reason) => { + expect(findSvgIconProblem(svg)).toBe(reason); + }); + + it("measures the size in UTF-8 bytes", () => { + const multibyte = "é".repeat(32 * 1024); + + expect(findSvgIconProblem(`${multibyte}`)).toBe("exceeds 64 KiB"); + }); +}); diff --git a/packages/plugin/src/icons.ts b/packages/plugin/src/icons.ts new file mode 100644 index 0000000000..558aa2497d --- /dev/null +++ b/packages/plugin/src/icons.ts @@ -0,0 +1,38 @@ +/** What a component icon receives from the surface that draws it. */ +export interface PluginIconProps { + size: number; + color: string; +} + +/** Inline SVG markup. Paseo validates it when the contribution registers. */ +export interface PluginSvgIcon { + svg: string; +} + +/** An icon that can be stored or sent: a Lucide icon name, or inline SVG. */ +export type PluginSerializableIcon = string | PluginSvgIcon; + +export const PLUGIN_SVG_ICON_MAX_BYTES = 64 * 1024; + +const EXTERNAL_HREF = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi; + +/** + * Why Paseo refuses to render this SVG, or null when it is a self-contained document. + * The daemon applies it to provider and usage-source icon files; the app to inline plugin icons. + */ +export function findSvgIconProblem(svg: string): string | null { + if (new TextEncoder().encode(svg).byteLength > PLUGIN_SVG_ICON_MAX_BYTES) { + return "exceeds 64 KiB"; + } + if (!/^\s*)/i.test(svg)) return "not an SVG document"; + if (/)/i.test(svg)) return "script elements are not allowed"; + if (/)/i.test(svg)) return "foreignObject elements are not allowed"; + if (/)/i.test(svg)) return "style elements are not allowed"; + if (/\son[a-z0-9_-]*\s*=/i.test(svg)) return "event-handler attributes are not allowed"; + if (/javascript\s*:/i.test(svg)) return "javascript URLs are not allowed"; + for (const match of svg.matchAll(EXTERNAL_HREF)) { + const href = match[2] ?? match[3] ?? ""; + if (!href.startsWith("#")) return "external href references are not allowed"; + } + return null; +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index a41bca2e5d..2bc20fa9ac 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -22,3 +22,10 @@ export { export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js"; export type { PluginText, PluginMessage } from "./text.js"; +export { + findSvgIconProblem, + PLUGIN_SVG_ICON_MAX_BYTES, + type PluginIconProps, + type PluginSerializableIcon, + type PluginSvgIcon, +} from "./icons.js"; diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 89bb95b50a..770ae5f50f 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.12.0-beta.3", + "version": "0.12.0", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index 0947b81027..a35852cc06 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -43,6 +43,7 @@ export const CLIENT_CAPS = { timelineNotifications: "timeline_notifications", // COMPAT(pluginTimelineItems): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. pluginTimelineItems: "plugin_timeline_items", + workspaceSetupObservation: "workspace_setup_observation", // COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. workspaceSetupBlocked: "workspace_setup_blocked", browserHost: "browser_host", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a7a2182d94..f7c43f224a 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2556,6 +2556,12 @@ export const WorkspaceSetupStatusRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceSetupSubscribeRequestSchema = z.object({ + type: z.literal("workspace.setup.subscribe.request"), + workspaceId: z.string(), + requestId: z.string(), +}); + export const WorkspaceSetupRunRequestSchema = z.object({ type: z.literal("workspace.setup.run.request"), workspaceId: z.string(), @@ -3335,6 +3341,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PaseoWorktreeArchiveRequestSchema, CreatePaseoWorktreeRequestSchema, WorkspaceSetupStatusRequestSchema, + WorkspaceSetupSubscribeRequestSchema, WorkspaceSetupRunRequestSchema, LegacyListAvailableEditorsRequestSchema, LegacyOpenInEditorRequestSchema, @@ -3725,6 +3732,7 @@ export const ServerInfoStatusPayloadSchema = z selectiveAgentTimeline: z.boolean().optional(), explicitEventSubscriptions: z.boolean().optional(), ownedSubscriptions: z.boolean().optional(), + workspaceSetupObservation: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), agentMessageProvenance: z.boolean().optional(), @@ -4035,6 +4043,8 @@ export const WorkspaceDescriptorPayloadSchema = z projectCustomIconRevision: z.string().nullable().optional(), projectRootPath: z.string(), workspaceDirectory: z.string().optional(), + // COMPAT(workspaceSetupObservation): added in v0.12.0, remove optional after 2027-04-19. + setupStatus: z.enum(["running", "completed", "failed", "blocked"]).nullable().optional(), // COMPAT(worktreeSlug): added in v0.2.6, remove optional after 2027-01-31. // Present only for Paseo-owned worktrees; this is the basename of their root directory. worktreeSlug: z.string().optional(), @@ -4422,6 +4432,16 @@ export const WorkspaceSetupSnapshotSchema = z.object({ .optional(), }); +export const WorkspaceSetupSubscribeResponseSchema = z.object({ + type: z.literal("workspace.setup.subscribe.response"), + payload: z.object({ + requestId: z.string(), + subscriptionId: z.string().optional(), + workspaceId: z.string(), + snapshot: WorkspaceSetupSnapshotSchema.nullable(), + }), +}); + export const WorkspaceSetupRunResponseMessageSchema = z.object({ type: z.literal("workspace.setup.run.response"), payload: z.object({ @@ -6909,6 +6929,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ScriptStatusUpdateMessageSchema, WorkspaceSetupProgressMessageSchema, WorkspaceSetupStatusResponseMessageSchema, + WorkspaceSetupSubscribeResponseSchema, WorkspaceSetupRunResponseMessageSchema, AgentStreamMessageSchema, AgentStatusMessageSchema, diff --git a/packages/relay/package.json b/packages/relay/package.json index 43f328c5cd..3cadb9c3e0 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.12.0-beta.3", + "version": "0.12.0", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 41457e6564..afc1c4a399 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.12.0-beta.3", + "version": "0.12.0", "description": "Paseo backend server", "files": [ "dist/server", @@ -107,11 +107,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.12.0-beta.3", - "@getpaseo/highlight": "0.12.0-beta.3", - "@getpaseo/plugin": "0.12.0-beta.3", - "@getpaseo/protocol": "0.12.0-beta.3", - "@getpaseo/relay": "0.12.0-beta.3", + "@getpaseo/client": "0.12.0", + "@getpaseo/highlight": "0.12.0", + "@getpaseo/plugin": "0.12.0", + "@getpaseo/protocol": "0.12.0", + "@getpaseo/relay": "0.12.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/server/src/server/agent/provider-runtime.ts b/packages/server/src/server/agent/provider-runtime.ts index 9094527739..398d35746d 100644 --- a/packages/server/src/server/agent/provider-runtime.ts +++ b/packages/server/src/server/agent/provider-runtime.ts @@ -9,7 +9,7 @@ import type { PaseoToolCatalog } from "./tools/types.js"; export interface AgentProviderRuntime { snapshotManager: ProviderSnapshotManager; - setPaseoToolCatalog(catalog: PaseoToolCatalog | null): void; + setPaseoToolCatalog(catalog: Pick | null): void; shutdown(): Promise; } diff --git a/packages/server/src/server/agent/providers/claude/agent.auto-plan-approval.test.ts b/packages/server/src/server/agent/providers/claude/agent.auto-plan-approval.test.ts new file mode 100644 index 0000000000..711dea79ec --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.auto-plan-approval.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "vitest"; +import type { CanUseTool, PermissionMode, Query } from "@anthropic-ai/claude-agent-sdk"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { AgentPermissionRequest } from "../../agent-sdk-types.js"; +import { ClaudeAgentClient } from "./agent.js"; + +// The injected SDK adapter supplies requests through Claude's permission callback. +async function createPlanApproval(mode: PermissionMode) { + let requestPermission: CanUseTool | undefined; + let sdkMode: PermissionMode = mode; + const query = Object.assign((async function* () {})(), { + async setPermissionMode(next: PermissionMode) { + sdkMode = next; + }, + close() {}, + }); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude", + queryFactory: ({ options }) => { + requestPermission = options.canUseTool; + return query as Query; + }, + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: mode, + }); + let request: AgentPermissionRequest | undefined; + session.subscribe((event) => { + if (event.type === "permission_requested") request = event.request; + }); + await session.setMode("plan"); + if (!requestPermission) throw new Error("SDK permission callback was not installed"); + const resolution = requestPermission( + "ExitPlanMode", + { plan: "Implement the plan" }, + { + signal: new AbortController().signal, + toolUseID: "plan-approval", + }, + ); + if (!request) throw new Error("No plan approval request was emitted"); + return { session, request, resolution, sdkMode: () => sdkMode }; +} + +test.each([ + ["auto", "Implement with Auto mode"], + ["bypassPermissions", "Implement with Bypass"], +] as const)("plan approval offers an explicit return to %s", async (mode, label) => { + const plan = await createPlanApproval(mode); + try { + expect(plan.request.actions).toContainEqual( + expect.objectContaining({ + id: "implement_resume", + label, + behavior: "allow", + intent: "implement_resume", + }), + ); + } finally { + await plan.session.respondToPermission(plan.request.id, { behavior: "deny" }); + await plan.resolution; + await plan.session.close(); + } +}); + +test.each([ + ["auto", "implement_resume", "auto"], + ["bypassPermissions", "implement_resume", "bypassPermissions"], + ["auto", "implement", "acceptEdits"], + ["bypassPermissions", "implement", "acceptEdits"], + ["default", "implement_resume", "acceptEdits"], + ["acceptEdits", "implement_resume", "acceptEdits"], +] as const)("approving from %s with %s enters %s", async (mode, action, expected) => { + const plan = await createPlanApproval(mode); + try { + await plan.session.respondToPermission(plan.request.id, { + behavior: "allow", + selectedActionId: action, + }); + await expect(plan.resolution).resolves.toMatchObject({ behavior: "allow" }); + expect(plan.sdkMode()).toBe(expected); + expect(await plan.session.getCurrentMode()).toBe(expected); + if (mode === "default" || mode === "acceptEdits") { + expect(plan.request.actions?.some((entry) => entry.id === "implement_resume")).toBe(false); + } + } finally { + await plan.session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 2af8983de0..c2f607665d 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1172,7 +1172,7 @@ function buildClaudePlanPermissionActions( }, ]; - if (resumeMode === "bypassPermissions") { + if (resumeMode === "bypassPermissions" || resumeMode === "auto") { actions.push({ id: "implement_resume", label: `Implement with ${getClaudeModeLabel(resumeMode)}`, @@ -2813,11 +2813,11 @@ class ClaudeAgentSession implements AgentSession { if (response.behavior === "allow") { if (pending.request.kind === "plan") { const selectedActionId = response.selectedActionId; + const resumeMode = this.planResumeMode; const shouldResumePriorMode = - selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions"; - const targetMode: PermissionMode = shouldResumePriorMode - ? "bypassPermissions" - : "acceptEdits"; + selectedActionId === "implement_resume" && + (resumeMode === "bypassPermissions" || resumeMode === "auto"); + const targetMode: PermissionMode = shouldResumePriorMode ? resumeMode : "acceptEdits"; await this.setMode(targetMode); this.pushToolCall( mapClaudeCompletedToolCall({ diff --git a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts index d3d295cc8e..843ef21ebb 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts @@ -13,6 +13,11 @@ import { getRealProviderRuntimeSettings, } from "../../daemon-e2e/real-provider-test-config.js"; import type { PaseoToolCatalog } from "../tools/types.js"; +import { AgentManager } from "../agent-manager.js"; +import { AgentStorage } from "../agent-storage.js"; +import { ProviderSnapshotManager } from "../provider-snapshot-manager.js"; +import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../tools/paseo-tools.js"; +import { serializePaseoToolInputParameters } from "../tools/paseo-tool-serialization.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js"; import { OpenCodeBridge } from "./opencode/bridge.js"; @@ -23,6 +28,61 @@ import { requireSessionId, } from "./opencode/test-utils/v2-local-e2e-helpers.js"; +test("real OpenCode server advertises agent-scoped notification descriptions", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-manifest-real-")); + const logger = createTestLogger(); + const providerSnapshotManager = new ProviderSnapshotManager({ logger }); + const dependencies = { + agentManager: new AgentManager({ logger }), + agentStorage: new AgentStorage(path.join(root, "agents"), logger), + providerSnapshotManager, + logger, + }; + const bridge = new OpenCodeBridge({ paseoHome: root, logger }); + bridge.setManifestCatalog(createPaseoAgentToolManifest(dependencies)); + const manager = new OpenCodeServerManager({ + logger, + resolveHomeDir: () => root, + runtimeSettings: { + env: { + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_CACHE_HOME: path.join(root, "cache"), + OPENCODE_DISABLE_AUTO_UPDATE: "1", + }, + }, + decorateServerEnv: (env) => bridge.decorateServerEnv(env), + }); + let inspection: Awaited> | undefined; + try { + await bridge.start(); + inspection = await manager.acquireCurrent(); + const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: root }); + const response = await sdk.tool.list({ directory: root, provider: "openai", model: "gpt-5.4" }); + expect(response.error).toBeUndefined(); + const agentCatalog = createPaseoToolCatalog({ ...dependencies, callerAgentId: "caller" }); + for (const name of ["create_agent", "send_agent_prompt"]) { + const actual = response.data!.find((tool) => tool.id === `paseo_${name}`)!; + expect(actual).toBeDefined(); + const expected = serializePaseoToolInputParameters(agentCatalog.getTool(name)!); + expect(actual.parameters).toMatchObject({ + properties: { + notifyOnFinish: { + description: (expected.properties as Record) + .notifyOnFinish.description, + }, + }, + }); + } + } finally { + await inspection?.release(); + await manager.shutdown(); + await bridge.close(); + await providerSnapshotManager.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + test("real OpenCode server persists provider permissions across creation and resume", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-")); const cwd = path.join(root, "repo"); diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 4b42410845..8fc6c23d93 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -5,15 +5,18 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { z } from "zod"; +import Ajv from "ajv"; import { afterEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; import { PaseoToolInputError } from "../../tools/types.js"; -import { createPaseoToolCatalog } from "../../tools/paseo-tools.js"; import { AgentManager } from "../../agent-manager.js"; import { AgentStorage } from "../../agent-storage.js"; import { ProviderSnapshotManager } from "../../provider-snapshot-manager.js"; +import { createTestAgentClients } from "../../../test-utils/fake-agent-client.js"; +import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../../tools/paseo-tools.js"; +import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js"; import { OpenCodeBridge, loadOpenCodeBridgePluginArtifact, @@ -72,6 +75,144 @@ function readPluginOptions(env: Record): { } describe("OpenCodeBridge", () => { + test("advertises agent-scoped schemas that execute through both bridge plugins", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-manifest-")); + temporaryDirectories.push(paseoHome); + const logger = createTestLogger(); + const clients = createTestAgentClients(); + const agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger); + const agentManager = new AgentManager({ clients, registry: agentStorage, logger }); + const providerSnapshotManager = new ProviderSnapshotManager({ logger, extraClients: clients }); + const dependencies = { agentManager, agentStorage, providerSnapshotManager, logger }; + const bridge = new OpenCodeBridge({ paseoHome, logger }); + await bridge.start(); + bridge.setManifestCatalog(createPaseoAgentToolManifest(dependencies)); + try { + const parent = await agentManager.createAgent( + { provider: "codex", cwd: paseoHome }, + undefined, + { workspaceId: "manifest-workspace" }, + ); + const catalog = createPaseoToolCatalog({ ...dependencies, callerAgentId: parent.id }); + bridge.bindSession({ sessionId: "bound", env: {}, tools: catalog }); + const plugin = readPluginOptions(bridge.decorateServerEnv({})); + const response = await fetch(`${plugin.baseUrl}/_internal/opencode/tools`, { + headers: { Authorization: `Bearer ${plugin.token}` }, + }); + expect(response.status).toBe(200); + const manifest = z + .object({ + tools: z.array( + z.object({ + name: z.string(), + description: z.string(), + inputSchema: z.record(z.string(), z.unknown()), + }), + ), + }) + .parse(await response.json()); + const definitions = new Map(manifest.tools.map((tool) => [tool.name, tool])); + const createDefinition = definitions.get("create_agent")!; + const sendDefinition = definitions.get("send_agent_prompt")!; + const topLevelCatalog = createPaseoToolCatalog(dependencies); + for (const name of ["create_agent", "send_agent_prompt"]) { + expect(serializePaseoToolInputParameters(topLevelCatalog.getTool(name)!)).toMatchObject({ + properties: { notifyOnFinish: { default: false } }, + }); + } + const ajv = new Ajv({ useDefaults: true, strict: false }); + const createInput = { + title: "Bridge child", + provider: "codex/gpt-5.4", + initialPrompt: "Hello", + notifyOnFinish: false, + }; + expect(ajv.compile(createDefinition.inputSchema)(createInput)).toBe(true); + + const v2Config = z + .object({ plugins: z.array(z.object({ package: z.string() })) }) + .parse(JSON.parse((await bridge.decorateV2ServerEnv({})).OPENCODE_CONFIG_CONTENT)); + const v2Module: { + default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; + } = await import( + pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href + ); + const tools = new Map(); + const dispose = await v2Module.default.setup({ + options: plugin, + tool: { + transform: async (transform) => { + transform({ + add: (tool) => { + tools.set(tool.name, tool); + }, + }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: "bound" }), + hook: async () => ({ dispose: async () => undefined }), + }, + }); + try { + const created = await tools + .get("paseo_create_agent")! + .execute(createInput, { sessionID: "bound" }); + const children = agentManager.listAgents().filter((agent) => agent.id !== parent.id); + expect(children).toHaveLength(1); + const child = children[0]!; + expect(created).toMatchObject({ + content: [{ type: "text", text: expect.stringContaining(child.id) }], + }); + expect(child.workspaceId).toBe("manifest-workspace"); + expect([...definitions.keys()]).toEqual([...catalog.tools.keys()]); + for (const definition of manifest.tools) { + const accepted = catalog.getTool(definition.name)!; + expect(definition.inputSchema).toEqual(serializePaseoToolInputParameters(accepted)); + expect(definition.description).toBe(accepted.description); + expect(tools.get(`paseo_${definition.name}`)!.input).toEqual(definition.inputSchema); + } + expect(createDefinition.inputSchema).toMatchObject({ + properties: { notifyOnFinish: { default: true } }, + }); + expect(createDefinition.inputSchema.properties).not.toHaveProperty("background"); + expect(sendDefinition.inputSchema).toMatchObject({ + properties: { notifyOnFinish: { default: true } }, + }); + await expect( + catalog.executeTool("create_agent", { ...createInput, background: true }), + ).rejects.toThrow("background"); + + const v1Module = await import(plugin.pluginUrl); + const hooks = await v1Module.default({}, plugin); + expect(Object.keys(hooks.tool.paseo_create_agent.args)).toEqual( + Object.keys( + z.record(z.string(), z.unknown()).parse(createDefinition.inputSchema.properties), + ), + ); + const sendInput = { agentId: child.id, prompt: "Hello again", notifyOnFinish: false }; + expect(ajv.compile(sendDefinition.inputSchema)(sendInput)).toBe(true); + await expect( + hooks.tool.paseo_send_agent_prompt.execute(sendInput, { + sessionID: "bound", + ask: async () => undefined, + }), + ).resolves.toMatchObject({ output: expect.stringContaining('"success": true') }); + } finally { + await dispose(); + } + } finally { + await bridge.close(); + await Promise.all( + agentManager.listAgents().map((agent) => agentManager.closeAgent(agent.id)), + ); + await agentManager.flushForShutdown(); + await providerSnapshotManager.shutdown(); + } + }); + test("loads packaged bundle bytes without invoking source compilation", async () => { const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-artifact-")); temporaryDirectories.push(root); @@ -696,6 +837,7 @@ describe("OpenCodeBridge", () => { interface V2TestTool { name: string; + input?: unknown; execute(input: unknown, call: { sessionID: string }): Promise; } interface V2TestContext { diff --git a/packages/server/src/server/agent/providers/opencode/bridge.ts b/packages/server/src/server/agent/providers/opencode/bridge.ts index 4e9902bd7a..7abe34475d 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.ts @@ -60,7 +60,7 @@ export class OpenCodeBridge { private baseUrl: string | null = null; private pluginUrl: string | null = null; private v2PluginUrl: string | null = null; - private manifestCatalog: PaseoToolCatalog | null = null; + private manifestCatalog: Pick | null = null; constructor(options: OpenCodeBridgeOptions) { this.paseoHome = options.paseoHome; @@ -89,7 +89,7 @@ export class OpenCodeBridge { this.baseUrl = `http://127.0.0.1:${address.port}`; } - setManifestCatalog(catalog: PaseoToolCatalog | null): void { + setManifestCatalog(catalog: Pick | null): void { this.manifestCatalog = catalog; } diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/refused-resume.json b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/refused-resume.json new file mode 100644 index 0000000000..b275677a29 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/refused-resume.json @@ -0,0 +1,68 @@ +{ + "provenance": { + "package": "@gotgenes/pi-subagents", + "version": "23.4.0", + "sourceCommit": "63476ff869efd8ea339040f496fa734048b89e70", + "piVersion": "0.84.4", + "captureDate": "2026-10-19" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "resume-call", + "toolName": "subagent", + "args": { + "subagent_type": "general-purpose", + "description": "resume", + "prompt": "Continue", + "resume": "expired-child", + "run_in_background": true + } + }, + { + "type": "tool_execution_end", + "toolCallId": "resume-call", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent \"expired-child\" had its session released after its retention window; resume is unavailable, but its result is still retrievable via get_subagent_result." + } + ] + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "resume-call", + "name": "subagent", + "arguments": { + "subagent_type": "general-purpose", + "description": "resume", + "prompt": "Continue", + "resume": "expired-child", + "run_in_background": true + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "resume-call", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Agent \"expired-child\" had its session released after its retention window; resume is unavailable, but its result is still retrievable via get_subagent_result." + } + ], + "isError": false + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts index 6bbe1b3b06..f89a9aefd3 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { fileURLToPath } from "node:url"; +import { ProviderSubagentStore } from "../../../../provider-subagents/store.js"; import { createPiExtensionHost } from "../index.js"; import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; import { streamPiHistory } from "../../history-mapper.js"; @@ -25,6 +26,31 @@ function publishService(agents: Array<{ id: string; outputFile?: string }>): voi } describe("@gotgenes/pi-subagents adapter", () => { + test.each([true, false])( + "removes a refused resume from live and replayed subagents (background=%s)", + async (background) => { + // The result came from 23.4.0's real AgentTool and SubagentManager after releaseSession(). + // The child session was a fake; releaseSession replaced waiting for the retention sweep. + const fixture = readSubagentFixture( + new URL("./fixtures/refused-resume.json", import.meta.url), + ); + const start = fixture.events[0]!; + if (start.type !== "tool_execution_start") throw new Error("Expected a tool start"); + start.args = { ...(start.args as object), run_in_background: background }; + const assistant = fixture.messages[0]!; + if (assistant.role !== "assistant") throw new Error("Expected an assistant tool call"); + for (const block of assistant.content) { + if (block.type === "toolCall") block.arguments = start.args; + } + const events = await verifySubagentFixture(fixture); + const store = new ProviderSubagentStore(); + store.apply("parent", "pi", { type: "upsert", id: "previous-child", status: "completed" }); + for (const event of events) store.apply("parent", "pi", event.event); + expect(events.map(({ event }) => event.type)).toEqual(["upsert", "remove"]); + expect(store.list("parent")).toMatchObject([{ id: "previous-child", status: "completed" }]); + }, + ); + test("reports a child's file after the child republishes the gotgenes service", async () => { const notifications: string[] = []; const emit = loadRuntimeBridge(); diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts index 62db93c79e..e1869705f8 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts @@ -86,7 +86,7 @@ export const gotgenesPiSubagents: PiExtension = { detail, subagents: [{ type: "upsert" as const, id: call.callId, status: "failed" as const }], } - : { detail }; + : { detail, subagents: [{ type: "remove" as const, id: call.callId }] }; callsByAgent.set(details.data.agentId, call.callId); const file = outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 34a8d5ebeb..30f28426b1 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -637,6 +637,20 @@ function resolveTerminalKeyToken({ key, literal, preamble }: TerminalKeyTokenInp } export function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog { + return createToolCatalog(options, options.callerAgentId ? "agent" : "top-level"); +} + +export function createPaseoAgentToolManifest( + options: PaseoToolHostDependencies, +): Pick { + // Plugins register before a caller exists, but every execution uses an agent-bound catalog. + return { tools: createToolCatalog(options, "agent").tools }; +} + +function createToolCatalog( + options: PaseoToolHostDependencies, + scope: "agent" | "top-level", +): PaseoToolCatalog { const { agentManager, agentStorage, @@ -1209,7 +1223,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."), }; const createAgentInputSchema = z - .object(callerAgentId ? agentToAgentInputSchema : canonicalTopLevelInputSchema) + .object(scope === "agent" ? agentToAgentInputSchema : canonicalTopLevelInputSchema) .passthrough(); const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict(); const legacyAgentToAgentCreateAgentArgsSchema = z.object(legacyAgentToAgentInputSchema).strict(); @@ -1245,9 +1259,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase "Agent-scoped only: get notified when the prompted agent finishes, errors, or needs permission.", ), }; - const sendAgentPromptInputSchema = callerAgentId - ? agentToAgentSendAgentPromptInputSchema - : topLevelSendAgentPromptInputSchema; + const sendAgentPromptInputSchema = + scope === "agent" ? agentToAgentSendAgentPromptInputSchema : topLevelSendAgentPromptInputSchema; const inspectProviderInputSchema = { provider: ProviderOrProviderModelInputSchema.describe( "Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).", @@ -2653,7 +2666,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .optional() .describe("IANA time zone for the cron cadence. For example: America/New_York."), name: z.string().optional(), - provider: (callerAgentId ? AgentProviderEnum.optional() : AgentProviderEnum).describe( + provider: (scope === "agent" ? AgentProviderEnum.optional() : AgentProviderEnum).describe( "Provider, or provider/model (for example: codex or codex/gpt-5.4). Defaults to the caller's provider in an agent-scoped session.", ), cwd: z.string().optional(), diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index e7e77bc0d3..0893abe1a3 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -210,6 +210,7 @@ const INBOUND_PERMISSION = { "workspace.script.stop.request": "workspace.write", "workspace.title.set.request": ["workspace.manage", "hub.execute"], workspace_setup_status_request: "workspace.read", + "workspace.setup.subscribe.request": "workspace.read", "workspace.setup.run.request": "workspace.write", write_project_config_request: "workspace.write", } as const satisfies Record; @@ -436,6 +437,7 @@ const OUTBOUND_PERMISSION = { "workspace.title.set.response": ["workspace.manage", "hub.execute"], workspace_setup_progress: "workspace.read", workspace_setup_status_response: "workspace.read", + "workspace.setup.subscribe.response": "workspace.read", "workspace.setup.run.response": "workspace.write", workspace_update: ["workspace.read", "hub.execute"], write_project_config_response: "workspace.write", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 727c7b7899..73e668735f 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -137,6 +137,7 @@ import { attachAgentStoragePersistence } from "./persistence-hooks.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; import { createPaseoToolCatalog, + createPaseoAgentToolManifest, type PaseoToolHostDependencies, } from "./agent/tools/paseo-tools.js"; import type { PaseoToolRuntimeContext } from "./agent/tools/types.js"; @@ -1457,7 +1458,9 @@ export async function createPaseoDaemon( const createAgentToolCatalog = (runtime: PaseoToolRuntimeContext) => createPaseoToolCatalog(createAgentToolHostDependencies(runtime)); const setAgentProviderToolsEnabled = (enabled: boolean) => { - agentProviderRuntime.setPaseoToolCatalog(enabled ? createAgentToolCatalog({}) : null); + agentProviderRuntime.setPaseoToolCatalog( + enabled ? createPaseoAgentToolManifest(createAgentToolHostDependencies({})) : null, + ); }; agentManager.setPaseoToolCatalogFactory(createAgentToolCatalog); agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false); diff --git a/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts index 8237ce9ef7..f9e2bd9c11 100644 --- a/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts @@ -288,6 +288,12 @@ async function runScenario(modeId: "auto" | "auto-review") { try { writeMockCodexConfig(codexHome, mockServer.url); await client.connect(); + const permissionEvents = client.observeEvents([ + "agent_permission_request", + "agent_permission_resolved", + ]); + permissionEvents.subscribe({ snapshot: () => {}, update: () => {} }); + await permissionEvents.ready; await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index fc86f85db3..6f694fbaa3 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -534,6 +534,25 @@ export type Value = string;`, expect(clientBundle).not.toContain("React.createElement"); }); + it("imports an SVG file from a runtime directory as its markup", async () => { + const entries = await createSplitPlugin(); + const svg = ''; + await writeFile(path.join(entries.directory, "client", "icon.svg"), svg); + await writeFile( + entries.client, + `import icon from "./client/icon.svg"; +export default function contribute(client) { + client.addCommandCenterItem({ id: "open", title: "Open", icon: { svg: icon }, context: "global", onSelect() {} }); + return () => undefined; +}`, + ); + const { clientBundle } = await compilePlugin(entries); + const registered: unknown[] = []; + const exports = new Function(`return ${clientBundle}`)()(() => undefined); + exports.default({ addCommandCenterItem: (item: unknown) => registered.push(item) }); + expect(registered).toMatchObject([{ icon: { svg } }]); + }); + it("lowers async callbacks before Hermes evaluates the client bundle", async () => { const entries = await createSplitPlugin(); await writeFile( diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index b48449bb50..4cf89a902c 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -393,6 +393,8 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom bundle: true, format: "cjs", jsx: "automatic", + // `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon. + loader: { ".svg": "text" }, platform: target === "server" ? "node" : "neutral", target: target === "server" ? "node20" : "es2020", // The neutral platform reads no package.json entry fields, so packages without diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts index 416616757d..a327310cc4 100644 --- a/packages/server/src/server/plugins/manifest.test.ts +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -68,11 +68,6 @@ describe("plugin manifest", () => { { id: "INVALID" }, { description: 42 }, { requirements: { paseoo: ">=0.11.0" } }, - { translations: { "zh-cn": { name: "示例" } } }, - { translations: { en: { name: "Example" } } }, - { translations: { "not a tag": { name: "Example" } } }, - { translations: { "zh-CN": { name: " " } } }, - { translations: { "zh-CN": { desc: "示例" } } }, ])("rejects invalid known fields: %j", async (fields) => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); directories.push(directory); @@ -133,18 +128,20 @@ describe("plugin manifest", () => { await expect(readPluginManifest(directory)).rejects.toThrow(); }); - it("reads listing translations with each field optional", async () => { - const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); - directories.push(directory); - const manifest = { - id: "described", - name: "Reviewer", - description: "Reviews changes before merge", - translations: { "zh-CN": { description: "合并前审查改动" } }, - }; - await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest)); - await expect(readPluginManifest(directory)).resolves.toEqual(manifest); - }); + // Listing translations are read by the registry when it publishes, never by the daemon, so a + // value the registry would refuse must not stop a plugin from installing. + it.each([{ "zh-cn": { name: "示例" } }, { "zh-CN": { desc: "示例" } }, "zh-CN"])( + "ignores listing translations: %j", + async (translations) => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "described", translations }), + ); + await expect(readPluginManifest(directory)).resolves.toEqual({ id: "described" }); + }, + ); it("accepts only non-empty argv arrays for build commands", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts index 39ddba422d..2312a2cb5a 100644 --- a/packages/server/src/server/plugins/manifest.ts +++ b/packages/server/src/server/plugins/manifest.ts @@ -22,27 +22,10 @@ const PluginAssetPathSchema = z value.split("/").at(-1) !== ".", "Expected a relative file path inside the plugin package", ); -// Keys are canonical language tags such as `zh-CN`, so a miscased `zh-cn` fails instead of never -// matching a reader's language. A misspelled field would silently show English, so entries are strict. -const PluginListingTranslationsSchema = z.record( - z - .string() - .refine( - (tag) => tag !== "en" && isCanonicalLanguageTag(tag), - "Expected a canonical language tag other than en, such as zh-CN", - ), - z - .object({ - name: z.string().trim().min(1).optional(), - description: z.string().trim().min(1).optional(), - }) - .strict(), -); const PluginManifestSchema = z.object({ id: PluginIdSchema, name: z.string().trim().min(1).optional(), description: z.string().trim().min(1).optional(), - translations: PluginListingTranslationsSchema.optional(), icon: PluginAssetPathSchema.refine( (value) => /\.png$/i.test(value), "Expected a relative path to a PNG", @@ -53,14 +36,6 @@ const PluginManifestSchema = z.object({ build: z.array(PluginBuildCommandSchema).min(1).optional(), }); -function isCanonicalLanguageTag(tag: string): boolean { - try { - return Intl.getCanonicalLocales(tag)[0] === tag; - } catch { - return false; - } -} - export type PluginManifest = z.infer; export async function readPluginManifest(directory: string): Promise { diff --git a/packages/server/src/server/plugins/provider-icon.test.ts b/packages/server/src/server/plugins/provider-icon.test.ts index 4faf9d289c..f8040ec869 100644 --- a/packages/server/src/server/plugins/provider-icon.test.ts +++ b/packages/server/src/server/plugins/provider-icon.test.ts @@ -29,18 +29,14 @@ describe("readPluginProviderIcon", () => { }); test.each([ - ["non-SVG document", "hello", "file is not an SVG document"], - ["script", "", "script elements"], - ["foreign object", "", "foreignObject elements"], - ["style", "", "style elements"], - ["event handler", '', "event-handler attributes"], - ["javascript URL", '', "javascript URLs"], + ["non-SVG document", "hello", "not an SVG document"], + ["script", "", "script elements are not allowed"], ["external href", '', "external href"], - ])("rejects %s", async (_name, svg, reason) => { + ])("rejects %s with the shared SVG rules", async (_name, svg, reason) => { const directory = await writeIcon(svg); await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow( - `icon.svg": ${reason}`, + `Invalid plugin provider icon "icon.svg": ${reason}`, ); }); @@ -78,7 +74,7 @@ describe("readPluginProviderIcon", () => { const directory = await writeIcon(`${" ".repeat(64 * 1024)}`); await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow( - "file exceeds 64 KiB", + `Invalid plugin provider icon "icon.svg": exceeds 64 KiB`, ); }); }); diff --git a/packages/server/src/server/plugins/provider-icon.ts b/packages/server/src/server/plugins/provider-icon.ts index 9a1e3f6714..af33ea82f5 100644 --- a/packages/server/src/server/plugins/provider-icon.ts +++ b/packages/server/src/server/plugins/provider-icon.ts @@ -1,7 +1,6 @@ import { readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; - -const MAX_ICON_BYTES = 64 * 1024; +import { findSvgIconProblem } from "@getpaseo/plugin"; export async function readPluginProviderIcon( pluginDirectory: string, @@ -20,10 +19,10 @@ export async function readPluginProviderIcon( if (!iconStat.isFile()) { throw iconError(iconPath, "file does not exist or is not a regular file"); } - if (iconStat.size > MAX_ICON_BYTES) throw iconError(iconPath, "file exceeds 64 KiB"); const svg = await readFile(resolvedIcon, "utf8"); - validateSvg(iconPath, svg); + const problem = findSvgIconProblem(svg); + if (problem) throw iconError(iconPath, problem); return svg; } @@ -38,27 +37,6 @@ function assertInsidePluginDirectory( if (escapesDirectory) throw iconError(iconPath, "path leaves the plugin directory"); } -function validateSvg(iconPath: string, svg: string): void { - if (!/^\s*)/i.test(svg)) throw iconError(iconPath, "file is not an SVG document"); - if (/)/i.test(svg)) throw iconError(iconPath, "script elements are not allowed"); - if (/)/i.test(svg)) { - throw iconError(iconPath, "foreignObject elements are not allowed"); - } - if (/)/i.test(svg)) throw iconError(iconPath, "style elements are not allowed"); - if (/\son[a-z0-9_-]*\s*=/i.test(svg)) { - throw iconError(iconPath, "event-handler attributes are not allowed"); - } - if (/javascript\s*:/i.test(svg)) throw iconError(iconPath, "javascript URLs are not allowed"); - - const hrefPattern = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi; - for (const match of svg.matchAll(hrefPattern)) { - const href = match[2] ?? match[3] ?? ""; - if (!href.startsWith("#")) { - throw iconError(iconPath, "external href references are not allowed"); - } - } -} - function iconError(iconPath: string, reason: string): Error { return new Error(`Invalid plugin provider icon "${iconPath}": ${reason}`); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 08d3f0eefb..87949594aa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -13,6 +13,7 @@ import { CreationService, type CreatedAgent } from "./creation/index.js"; import type { CreationSnapshot, AgentCreateRequest } from "@getpaseo/protocol/messages"; import type { MessageReceipts } from "./message-receipts/index.js"; import equal from "fast-deep-equal"; +import { WorkspaceSetupObservation } from "./workspace-setup-observation.js"; import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; import { v4 as uuidv4 } from "uuid"; import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; @@ -531,7 +532,7 @@ export interface SessionOptions { hubRelationships?: HubRelationshipManagement; serviceProxy?: ServiceProxySubsystem; scriptRuntimeStore?: WorkspaceScriptRuntimeStore; - workspaceSetupSnapshots?: Map; + workspaceSetupObservation?: WorkspaceSetupObservation; workspaceSetupRuntime?: WorkspaceSetupRuntime; onBranchChanged?: ( workspaceId: string, @@ -783,7 +784,8 @@ export class Session { private readonly terminalController: TerminalSessionController; private inflightRequests = 0; private peakInflightRequests = 0; - private readonly workspaceSetupSnapshots: Map; + private readonly workspaceSetupObservation: WorkspaceSetupObservation; + private readonly unsubscribeSetupObservation: () => void; private readonly workspaceSetupRuntime: WorkspaceSetupRuntime; private readonly workspaceGitObserver: WorkspaceGitObserverService; private readonly workspaceDirectory: WorkspaceDirectory; @@ -844,7 +846,7 @@ export class Session { providerSnapshotManager, serviceProxy, scriptRuntimeStore, - workspaceSetupSnapshots, + workspaceSetupObservation, workspaceSetupRuntime, onBranchChanged, getDaemonTcpPort, @@ -1151,7 +1153,19 @@ export class Session { this.providerSnapshotManager = providerSnapshotManager; this.serviceProxy = serviceProxy ?? null; this.scriptRuntimeStore = scriptRuntimeStore ?? null; - this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map(); + this.workspaceSetupObservation = workspaceSetupObservation ?? new WorkspaceSetupObservation(); + this.unsubscribeSetupObservation = this.workspaceSetupObservation.subscribe( + (workspaceId, snapshot, statusChanged) => { + if (statusChanged) + void this.emitWorkspaceUpdateForWorkspaceId(workspaceId).catch((error) => + this.sessionLogger.warn({ err: error, workspaceId }, "Failed to publish setup status"), + ); + this.emitSubscribedEvent({ + type: "workspace_setup_progress", + payload: { workspaceId, ...snapshot }, + }); + }, + ); this.workspaceSetupRuntime = resolveWorkspaceSetupRuntime(workspaceSetupRuntime); this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpHost = getDaemonTcpHost ?? null; @@ -2293,10 +2307,50 @@ export class Session { } } + private async subscribeWorkspaceSetup( + request: Extract, + ): Promise { + if ( + !this.supports(CLIENT_CAPS.workspaceSetupObservation) || + !this.delivery.isModern(this.delivery.currentSource) + ) + throw new Error("Workspace setup observation requires owned subscriptions"); + // Resolve persisted blocked state first. Register and read the current cache in one + // synchronous turn afterward, so a setup transition during this read cannot be lost. + let initial: WorkspaceSetupSnapshot | null = null; + await handleWorkspaceSetupStatusRequestMessage( + { + workspaceSetupSnapshots: this.workspaceSetupObservation, + getWorkspace: (id) => this.workspaceRegistry.get(id), + emit: (message) => { + if (message.type === "workspace_setup_status_response") + initial = message.payload.snapshot; + }, + }, + { ...request, type: "workspace_setup_status_request" }, + ); + let stop: (() => void) | undefined; + const owner = this.delivery.begin("workspace-setup", undefined, () => stop?.()); + stop = this.workspaceSetupObservation.subscribe((workspaceId, snapshot) => { + if (workspaceId === request.workspaceId) + owner.emit({ type: "workspace_setup_progress", payload: { workspaceId, ...snapshot } }); + }); + this.emit({ + type: "workspace.setup.subscribe.response", + payload: { + requestId: request.requestId, + subscriptionId: owner.responseId, + workspaceId: request.workspaceId, + snapshot: this.workspaceSetupObservation.get(request.workspaceId) ?? initial, + }, + }); + } + private dispatchSubscriptionMessage( msg: SessionInboundMessage, source?: object, ): Promise | undefined { + if (msg.type === "workspace.setup.subscribe.request") return this.subscribeWorkspaceSetup(msg); if (msg.type === "browser.host.register.request") return this.registerBrowserHost(msg); if (msg.type === "browser.automation.execute.response") { if (source) @@ -5602,6 +5656,7 @@ export class Session { projectCustomIconRevision: resolvedProjectRecord?.customIconRevision ?? null, projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, workspaceDirectory: workspace.cwd, + setupStatus: this.workspaceSetupObservation.status(workspace), worktreeSlug, projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git", workspaceKind: workspace.kind, @@ -5692,6 +5747,7 @@ export class Session { projectCustomIconRevision: projectRecord?.customIconRevision ?? null, projectRootPath: projectRecord?.rootPath ?? result.repoRoot, workspaceDirectory: result.workspace.cwd, + setupStatus: this.workspaceSetupObservation.status(result.workspace), worktreeSlug: basename(result.worktree.worktreePath), projectKind: projectRecord?.kind ?? "git", workspaceKind: result.workspace.kind, @@ -7365,7 +7421,7 @@ export class Session { emitWorkspaceUpdateForWorkspaceId: (workspaceId) => this.emitWorkspaceUpdateForWorkspaceId(workspaceId), cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => { - this.workspaceSetupSnapshots.set(workspaceId, snapshot); + this.workspaceSetupObservation.cache(workspaceId, snapshot); }, emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, @@ -7390,7 +7446,7 @@ export class Session { return handleWorkspaceSetupStatusRequestMessage( { emit: (message) => this.emit(message), - workspaceSetupSnapshots: this.workspaceSetupSnapshots, + workspaceSetupSnapshots: this.workspaceSetupObservation, getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), }, request, @@ -7412,7 +7468,7 @@ export class Session { emitWorkspaceUpdateForWorkspaceId: (workspaceId) => this.emitWorkspaceUpdateForWorkspaceId(workspaceId), cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => - this.workspaceSetupSnapshots.set(workspaceId, snapshot), + this.workspaceSetupObservation.cache(workspaceId, snapshot), emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, @@ -8392,8 +8448,22 @@ export class Session { */ // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08. private wantsEvent(event: SessionEventSubscription, source?: object): boolean { + // COMPAT(workspaceSetupObservation): added in v0.12.0, remove global setup delivery after 2027-04-19 once client floor >= v0.12.0. + if ( + event === "workspace_setup_progress" && + source && + this.supportsForSource(CLIENT_CAPS.workspaceSetupObservation, source) + ) + return false; for (const subscription of this.eventSubscriptions.values()) { - if ((!source || subscription.owner.source === source) && subscription.events.has(event)) + if ( + (!source || subscription.owner.source === source) && + subscription.events.has(event) && + !( + event === "workspace_setup_progress" && + this.supportsForSource(CLIENT_CAPS.workspaceSetupObservation, subscription.owner.source) + ) + ) return true; } if (!source && this.clientSources.size > 0) { @@ -8441,6 +8511,11 @@ export class Session { for (const subscription of this.eventSubscriptions.values()) { if ( !subscription.events.has(event) || + (event === "workspace_setup_progress" && + this.supportsForSource( + CLIENT_CAPS.workspaceSetupObservation, + subscription.owner.source, + )) || (onlySource && subscription.owner.source !== onlySource) ) continue; @@ -8464,6 +8539,11 @@ export class Session { private emit(msg: SessionOutboundMessage): void { if (!this.authorization.allowsOutbound(msg)) return; + if (msg.type === "workspace_setup_progress") { + const { workspaceId, subscriptionId: _subscriptionId, ...snapshot } = msg.payload; + this.workspaceSetupObservation.publish(workspaceId, snapshot); + return; + } if (this.delivery.reply(msg)) return; if (this.emitSubscribedEvent(msg)) return; // JSON.stringify(msg) is only computed when trace is enabled — it runs for @@ -8478,7 +8558,7 @@ export class Session { "agent.session.outbound", ); } - if (msg.type === "workspace_setup_progress" || msg.type === "workspace_setup_status_response") { + if (msg.type === "workspace_setup_status_response") { if (this.clientSources.size > 0 && this.onMessageToSource) { for (const [source] of this.clientSources) { this.onMessageToSource(source, this.workspaceSetupMessageForClient(msg, source)); @@ -8552,6 +8632,7 @@ export class Session { public async cleanup(): Promise { this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup"); this.isCleanedUp = true; + this.unsubscribeSetupObservation(); await this.delivery.close(); if (this.unsubscribeAgentEvents) { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index b9c04e8244..fafcbc182a 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -175,6 +175,7 @@ interface SessionTestAccess { emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable): Promise; updateClientCapabilities(capabilities: Record | null, source?: object): void; + publish: Session["publish"]; emit(message: unknown): void; onMessage(message: unknown): void; paseoHome: string; @@ -9602,3 +9603,82 @@ test("workspace.create.request reports an archived explicit project", async () = errorCode: "archived_project", }); }); + +test.each(["failed", "blocked"] as const)( + "workspace setup %s catches up from an existing directory cursor after releasing observation", + async (status) => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + }); + const source = {}; + const capabilities = { + [CLIENT_CAPS.ownedSubscriptions]: true, + [CLIENT_CAPS.workspaceSetupObservation]: true, + }; + session.updateClientCapabilities(capabilities, source); + await session.handleMessage( + { type: "fetch_workspaces_request", requestId: "before-setup", subscribe: {}, sync: {} }, + source, + ); + const initial = findByType(emitted, "fetch_workspaces_response")!.payload; + expect(initial.entries).toEqual([ + expect.objectContaining({ id: "ws-repo-running", setupStatus: null }), + ]); + expect(initial.sync?.mode).toBe("snapshot"); + await session.handleMessage( + { + type: "subscription.release.request", + requestId: "release-directory", + subscriptionId: initial.subscriptionId, + }, + source, + ); + emitted.length = 0; + session.publish({ + type: "workspace_setup_progress", + payload: { + workspaceId: "ws-repo-running", + status, + error: status === "failed" ? "setup failed" : null, + detail: { + type: "worktree_setup", + worktreePath: REPO_CWD, + branchName: "main", + log: "output never requested", + commands: [], + }, + }, + }); + await flushWorkspaceUpdateBackgroundWork(); + expect(emitted).toEqual([]); + const reconnectSource = {}; + session.updateClientCapabilities(capabilities, reconnectSource); + await session.handleMessage( + { + type: "fetch_workspaces_request", + requestId: "catch-up-setup", + subscribe: {}, + sync: { generation: initial.sync!.generation, afterSeq: initial.sync!.headSeq }, + }, + reconnectSource, + ); + const caughtUp = findByType(emitted, "fetch_workspaces_response")!.payload; + expect(caughtUp.sync?.mode).toBe("changes"); + expect(caughtUp.entries).toEqual([ + expect.objectContaining({ id: "ws-repo-running", setupStatus: status }), + ]); + expect(caughtUp.entries[0].syncSeq).toBeGreaterThan(initial.sync!.headSeq); + expect(filterByType(emitted, "workspace_setup_progress")).toEqual([]); + emitted.length = 0; + await session.handleMessage( + { + type: "fetch_workspaces_request", + requestId: "after-catch-up", + sync: { generation: caughtUp.sync!.generation, afterSeq: caughtUp.sync!.headSeq }, + }, + reconnectSource, + ); + expect(findByType(emitted, "fetch_workspaces_response")!.payload.entries).toEqual([]); + }, +); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index e833a07ce3..b95e7696e2 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -22,7 +22,6 @@ import { type ServerInfoStatusPayload, type ScriptStatusUpdateMessage, type SessionOutboundMessage, - type WorkspaceSetupSnapshot, type WSHelloMessage, type WSInboundMessage, WSInboundMessageSchema, @@ -43,6 +42,7 @@ import { type SessionRuntimeMetrics, } from "./session.js"; import type { HubRelationshipManagement } from "./hub/relationship-controller.js"; +import { WorkspaceSetupObservation } from "./workspace-setup-observation.js"; import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js"; import type { HubExecutionAgents } from "./hub/daemon-executions.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; @@ -570,7 +570,7 @@ export class VoiceAssistantWebSocketServer { } | null; private readonly voiceSpeakHandlers = new Map(); private readonly voiceCallerContexts = new Map(); - private readonly workspaceSetupSnapshots = new Map(); + private readonly workspaceSetupObservation = new WorkspaceSetupObservation(); private readonly workspaceSetupRuntime: WorkspaceSetupRuntime; private readonly providerSnapshotManager: ProviderSnapshotManager; private onLifecycleIntent!: ((intent: SessionLifecycleIntent) => void) | null; @@ -955,6 +955,13 @@ export class VoiceAssistantWebSocketServer { } public broadcast(message: WSOutboundMessage): void { + // External setup producers (MCP and schedules) publish once, including when no + // clients are connected. Session-owned observers perform the source routing. + if (message.type === "session" && message.message.type === "workspace_setup_progress") { + const { workspaceId, subscriptionId: _subscriptionId, ...snapshot } = message.message.payload; + this.workspaceSetupObservation.publish(workspaceId, snapshot); + return; + } if (message.type === "session") { for (const connection of new Set(this.sessions.values())) { connection.session.publish(message.message); @@ -1506,7 +1513,7 @@ export class VoiceAssistantWebSocketServer { hubRelationships: options.hubRelationships, serviceProxy: this.serviceProxy ?? undefined, scriptRuntimeStore: this.scriptRuntimeStore ?? undefined, - workspaceSetupSnapshots: this.workspaceSetupSnapshots, + workspaceSetupObservation: this.workspaceSetupObservation, workspaceSetupRuntime: this.workspaceSetupRuntime, onBranchChanged: this.onBranchChanged ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, @@ -1803,6 +1810,7 @@ export class VoiceAssistantWebSocketServer { ...(this.workspaceLabelService ? { workspaceLabels: true } : {}), // COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02. workspaceSetupRun: true, + workspaceSetupObservation: true, // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. providersSnapshot: true, // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 34abdebc3f..22c8661544 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -15,6 +15,7 @@ import { type SessionOutboundMessage, } from "@getpaseo/protocol/messages"; import { Session, type SessionOptions } from "./session.js"; +import { WorkspaceSetupObservation } from "./workspace-setup-observation.js"; import { OWNER_PERMISSIONS } from "./authorization/index.js"; import { DirectorySyncService } from "./directory-sync/index.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; @@ -190,6 +191,7 @@ function createSessionForWireCompatTest(options?: { messages?: SessionOutboundMessage[]; onMessageToSource?: SessionOptions["onMessageToSource"]; rows?: AgentTimelineRow[]; + workspaceRegistry?: SessionOptions["workspaceRegistry"]; }): Session { const messages = options?.messages ?? []; const rows: AgentTimelineRow[] = [ @@ -228,7 +230,8 @@ function createSessionForWireCompatTest(options?: { agentStorage: new EmptyAgentStorage() as unknown as SessionOptions["agentStorage"], projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"], workspaceRegistry: - new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"], + options?.workspaceRegistry ?? + (new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"]), directorySync: options?.directorySync, scheduleService: {} as SessionOptions["scheduleService"], checkoutDiffManager: { @@ -641,3 +644,165 @@ test("setup progress is adapted per socket without changing the canonical snapsh expect(message.payload.status).toBe("blocked"); await session.cleanup(); }); + +test("scoped setup clients never receive session-wide setup output", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForWireCompatTest({ + messages, + clientCapabilities: { owned_subscriptions: true, workspace_setup_observation: true }, + }); + const source = {}; + session.updateClientCapabilities( + { owned_subscriptions: true, workspace_setup_observation: true }, + source, + ); + await session.handleMessage( + { + type: "session.events.set_subscription.request", + requestId: "events", + events: ["workspace_setup_progress"], + }, + source, + ); + messages.length = 0; + for (const workspaceId of ["setup-a", "setup-b", "setup-c"]) { + for (let chunk = 0; chunk < 10; chunk++) { + session.publish({ + type: "workspace_setup_progress", + payload: { + workspaceId, + status: "running", + error: null, + detail: { + type: "worktree_setup", + worktreePath: "/workspace", + branchName: "setup", + log: "output".repeat(chunk * 1000), + commands: [], + }, + }, + }); + } + } + expect(messages).toEqual([]); + await session.cleanup(); +}); + +test("setup output invalidates directory metadata only when visible setup status changes", () => { + const setup = new WorkspaceSetupObservation(); + const earlier = { + status: "failed" as const, + error: "failed before any connection", + detail: { + type: "worktree_setup" as const, + worktreePath: "/workspace", + branchName: "test", + log: "earlier output", + commands: [], + }, + }; + setup.publish("earlier", earlier); + expect(setup.get("earlier")).toEqual(earlier); + expect(setup.status({ workspaceId: "earlier", untrustedSource: null })).toBe("failed"); + const changes: boolean[] = []; + setup.subscribe((_workspaceId, _snapshot, changed) => changes.push(changed)); + const detail = { + type: "worktree_setup" as const, + worktreePath: "/workspace", + branchName: "test", + log: "", + commands: [ + { + index: 0, + command: "setup", + cwd: "/workspace", + log: "", + status: "running" as const, + exitCode: null, + }, + ], + }; + for (const log of ["", "one", "one two", "one two three"]) + setup.publish("workspace", { status: "running", error: null, detail: { ...detail, log } }); + setup.publish("workspace", { status: "failed", error: "failed", detail }); + expect(changes).toEqual([true, false, false, false, true]); + expect(setup.status({ workspaceId: "workspace", untrustedSource: null })).toBe("failed"); + expect(setup.status({ workspaceId: "empty", untrustedSource: null })).toBeNull(); + setup.publish("empty", { status: "completed", error: null, detail: { ...detail, commands: [] } }); + expect(setup.status({ workspaceId: "empty", untrustedSource: null })).toBeNull(); +}); + +test("setup observation includes a transition that happens during its initial registry read", async () => { + let completeRead!: (value: null) => void; + let readStarted!: () => void; + const started = new Promise((resolve) => { + readStarted = resolve; + }); + const pendingRead = new Promise((resolve) => { + completeRead = resolve; + }); + const messages: SessionOutboundMessage[] = []; + const source = {}; + const session = createSessionForWireCompatTest({ + messages, + clientCapabilities: { owned_subscriptions: true, workspace_setup_observation: true }, + workspaceRegistry: { + get: () => { + readStarted(); + return pendingRead; + }, + list: () => [], + } as unknown as SessionOptions["workspaceRegistry"], + }); + session.updateClientCapabilities( + { owned_subscriptions: true, workspace_setup_observation: true }, + source, + ); + try { + const request = session.handleMessage( + { type: "workspace.setup.subscribe.request", workspaceId: "setup", requestId: "observe" }, + source, + ); + await started; + session.publish({ + type: "workspace_setup_progress", + payload: { + workspaceId: "setup", + status: "failed", + error: "bootstrap failure", + detail: { + type: "worktree_setup", + worktreePath: "/workspace", + branchName: "test", + log: "failure output", + commands: [], + }, + }, + }); + completeRead(null); + await request; + expect(messages).toEqual([ + { + type: "workspace.setup.subscribe.response", + payload: { + requestId: "observe", + workspaceId: "setup", + subscriptionId: expect.any(String), + snapshot: { + status: "failed", + error: "bootstrap failure", + detail: { + type: "worktree_setup", + worktreePath: "/workspace", + branchName: "test", + log: "failure output", + commands: [], + }, + }, + }, + }, + ]); + } finally { + await session.cleanup(); + } +}); diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index 69c1da2efc..80208fe63d 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -6,6 +6,13 @@ import path from "node:path"; import { expect, onTestFinished, test } from "vitest"; import { DaemonClient } from "./test-utils/index.js"; +import { WebSocket } from "ws"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { + DaemonClient as SocketDaemonClient, + type WebSocketLike, +} from "@getpaseo/client/internal/daemon-client"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; // The reshaped workspace.create.request forwards its worktree `source` @@ -596,3 +603,249 @@ async function expectMissingLocalMergeTarget({ client, cwd }: RestorableWorkspac ).error?.message, ).toContain("No local merge target is recorded"); } + +// Setup output is opt-in even for multiple sockets sharing one logical client. +test("setup details follow one workspace/source and restore after reconnect with minimal directory status", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: true }); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const clients: SocketDaemonClient[] = []; + const sockets = new Map(); + const observations: Array<{ release(): Promise }> = []; + const workspaces: Array<{ id: string; workspaceDirectory?: string }> = []; + const mcp = new Client({ name: "setup-test", version: "1.0.0" }); + try { + await mcp.connect( + new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${daemon.port}/mcp/agents`)), + ); + writeFileSync( + path.join(repoDir, "paseo.json"), + JSON.stringify({ worktree: { setup: "node setup.cjs" } }), + ); + writeFileSync( + path.join(repoDir, "setup.cjs"), + ` +const fs = require('node:fs'); +// Exercise a real child start beyond Vitest's default one-second polling window. +setTimeout(() => { +console.log('setup started'); +let wave = 0; +const timer = setInterval(() => { + if (fs.existsSync('wave-' + wave)) { + console.log('wave-' + wave + ':' + 'setup output '.repeat(1000)); + fs.writeFileSync('emitted-' + wave, 'ready'); + wave++; + } + if (fs.existsSync('finish.txt')) { clearInterval(timer); process.exit(1); } +}, 20); +}, 1500); +`, + ); + writeFileSync(path.join(repoDir, ".gitignore"), "*.txt\nwave-*\nemitted-*\n"); + execFileSync("git", ["add", "paseo.json", "setup.cjs", ".gitignore"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "setup fixture"], { + cwd: repoDir, + stdio: "pipe", + }); + const connect = async (clientId: string) => { + const client = new SocketDaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + clientId, + reconnect: { baseDelayMs: 10, maxDelayMs: 10 }, + webSocketFactory: (url) => { + const socket = new WebSocket(url); + sockets.set(clientId, [...(sockets.get(clientId) ?? []), socket]); + return socket as unknown as WebSocketLike; + }, + }); + clients.push(client); + await client.connect(); + return client; + }; + const creator = await connect("setup-creator"); + const viewer = await connect("setup-viewer"); + const sibling = await connect("setup-viewer"); + const quiet = await connect("setup-quiet"); + const viewerMessages: import("@getpaseo/protocol/messages").SessionOutboundMessage[] = []; + const quietMessages: import("@getpaseo/protocol/messages").SessionOutboundMessage[] = []; + const siblingMessages: import("@getpaseo/protocol/messages").SessionOutboundMessage[] = []; + viewer.subscribeRawMessages((message) => viewerMessages.push(message)); + quiet.subscribeRawMessages((message) => quietMessages.push(message)); + sibling.subscribeRawMessages((message) => siblingMessages.push(message)); + const directory = quiet.observeWorkspaces(); + observations.push(directory); + await directory.ready; + const waitForSetupStarted = async (workspaceId: string) => { + // Process startup is asynchronous, especially under Windows PowerShell. The daemon's + // output snapshot is the readiness barrier; a marker file can precede PTY delivery. + await expect + .poll(async () => (await viewer.fetchWorkspaceSetupStatus(workspaceId)).snapshot, { + timeout: 30000, + }) + .toMatchObject({ + status: "running", + detail: { log: expect.stringContaining("setup started") }, + }); + }; + for (const worktreeSlug of ["noisy-a", "noisy-b", "noisy-c"]) { + const result = await creator.createWorkspace({ + source: { kind: "worktree", cwd: repoDir, worktreeSlug }, + }); + if (!result.workspace || result.error) throw new Error(result.error ?? "No workspace"); + workspaces.push(result.workspace); + await waitForSetupStarted(result.workspace.id); + } + const external = await mcp.callTool({ + name: "create_workspace", + arguments: { isolation: "worktree", path: repoDir, worktreeSlug: "noisy-mcp" }, + }); + if (typeof external.structuredContent?.workspaceId !== "string") + throw new Error("Missing MCP workspace"); + const externalWorkspace = (await creator.fetchWorkspaces()).entries.find( + (entry) => entry.id === external.structuredContent!.workspaceId, + ); + if (!externalWorkspace) throw new Error("Missing MCP directory entry"); + workspaces.unshift(externalWorkspace); + await waitForSetupStarted(externalWorkspace.id); + const workspace = workspaces[0]!; + const details = viewer.observeWorkspaceSetup(workspace.id); + observations.push(details); + const initial = await details.ready; + expect(initial.snapshot).toMatchObject({ + status: "running", + detail: { log: expect.stringContaining("setup started") }, + }); + await expect + .poll( + () => + new Set( + quietMessages + .filter( + (message) => + message.type === "workspace_update" && + message.payload.kind === "upsert" && + message.payload.workspace.setupStatus === "running", + ) + .map((message) => + message.type === "workspace_update" && message.payload.kind === "upsert" + ? message.payload.workspace.id + : "", + ), + ).size, + ) + .toBe(4); + for (const entry of workspaces) { + writeFileSync(path.join(entry.workspaceDirectory!, "wave-0"), "go"); + await expect + .poll(() => existsSync(path.join(entry.workspaceDirectory!, "emitted-0"))) + .toBe(true); + } + await expect + .poll( + () => + viewerMessages.filter( + (message) => + message.type === "workspace_setup_progress" && + message.payload.detail.log.includes("wave-0:"), + ).length, + ) + .toBeGreaterThan(0); + expect( + viewerMessages + .filter((message) => message.type === "workspace_setup_progress") + .map((message) => message.payload.workspaceId), + ).toEqual(expect.arrayContaining([workspace.id])); + expect( + new Set( + viewerMessages + .filter((message) => message.type === "workspace_setup_progress") + .map((message) => message.payload.workspaceId), + ), + ).toEqual(new Set([workspace.id])); + expect(quietMessages.filter((message) => message.type === "workspace_setup_progress")).toEqual( + [], + ); + expect( + siblingMessages.filter((message) => message.type === "workspace_setup_progress"), + ).toEqual([]); + for (const message of quietMessages) { + if (message.type !== "workspace_update" || message.payload.kind !== "upsert") continue; + expect(message.payload.workspace).not.toHaveProperty("detail"); + expect(message.payload.workspace).not.toHaveProperty("error"); + } + const waveLogs = viewerMessages + .filter( + (message) => + message.type === "workspace_setup_progress" && + message.payload.detail.log.includes("wave-0:"), + ) + .map((message) => + message.type === "workspace_setup_progress" ? message.payload.detail.log : "", + ); + expect(waveLogs.length).toBe(new Set(waveLogs).size); + const restoredSnapshots: unknown[] = []; + details.subscribe({ + snapshot: (snapshot) => restoredSnapshots.push(snapshot), + update: () => {}, + }); + const beforeReconnect = details.subscriptionId; + sockets.get("setup-viewer")![0]!.terminate(); + await expect.poll(() => restoredSnapshots.length, { timeout: 10000 }).toBe(2); + expect(details.subscriptionId).not.toBe(beforeReconnect); + expect(restoredSnapshots[1]).toMatchObject({ + snapshot: { detail: { log: expect.stringContaining("wave-0:") } }, + }); + const subscriptionId = details.subscriptionId; + await details.release(); + viewerMessages.length = 0; + writeFileSync(path.join(workspace.workspaceDirectory!, "wave-1"), "go"); + await expect + .poll(() => existsSync(path.join(workspace.workspaceDirectory!, "emitted-1"))) + .toBe(true); + // Wait until the daemon has consumed the output before checking wire silence. + await expect + .poll( + async () => (await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot?.detail.log, + { + timeout: 30000, + }, + ) + .toContain("wave-1:"); + expect(viewerMessages.filter((message) => message.type === "workspace_setup_progress")).toEqual( + [], + ); + const resumed = viewer.observeWorkspaceSetup(workspace.id); + observations.push(resumed); + expect((await resumed.ready).snapshot?.detail.log).toContain("wave-1:"); + expect(resumed.subscriptionId).not.toBe(subscriptionId); + writeFileSync(path.join(workspaces[1]!.workspaceDirectory!, "finish.txt"), "fail"); + await expect + .poll( + () => + quietMessages.filter( + (message) => + message.type === "workspace_update" && + message.payload.kind === "upsert" && + message.payload.workspace.id === workspaces[1]!.id && + message.payload.workspace.setupStatus === "failed", + ).length, + { timeout: 30000 }, + ) + .toBe(1); + expect(quietMessages.filter((message) => message.type === "workspace_setup_progress")).toEqual( + [], + ); + await resumed.release(); + await directory.release(); + } finally { + // End every fixture command and release observations while transports are live, + // including when an assertion fails before the normal release path. + for (const workspace of workspaces) { + writeFileSync(path.join(workspace.workspaceDirectory!, "finish.txt"), "stop"); + } + for (const observation of observations) await observation.release(); + await mcp.close(); + for (const client of clients) await client.close(); + await daemon.close(); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 180000); diff --git a/packages/server/src/server/workspace-setup-observation.ts b/packages/server/src/server/workspace-setup-observation.ts new file mode 100644 index 0000000000..69ad434f57 --- /dev/null +++ b/packages/server/src/server/workspace-setup-observation.ts @@ -0,0 +1,56 @@ +import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; +import type { WorkspaceSetupSnapshot } from "@getpaseo/protocol/messages"; + +type Listener = ( + workspaceId: string, + snapshot: WorkspaceSetupSnapshot, + statusChanged: boolean, +) => void; + +/** Daemon-owned setup snapshots, shared by sessions; output never invalidates the directory. */ +export class WorkspaceSetupObservation { + private readonly listeners = new Set(); + private readonly statuses = new Map(); + + private readonly snapshots = new Map(); + + get(workspaceId: string): WorkspaceSetupSnapshot | undefined { + return this.snapshots.get(workspaceId); + } + + cache(workspaceId: string, snapshot: WorkspaceSetupSnapshot): void { + this.snapshots.set(workspaceId, snapshot); + } + + publish(workspaceId: string, snapshot: WorkspaceSetupSnapshot): void { + const status = this.summary(snapshot); + const statusChanged = (this.statuses.get(workspaceId) ?? null) !== status; + this.statuses.set(workspaceId, status); + this.snapshots.set(workspaceId, snapshot); + for (const listener of this.listeners) listener(workspaceId, snapshot, statusChanged); + } + + status( + workspace: Pick, + ): WorkspaceSetupSnapshot["status"] | null { + const snapshot = this.snapshots.get(workspace.workspaceId); + if (snapshot) return this.summary(snapshot); + return workspace.untrustedSource ? "blocked" : null; + } + + private summary(snapshot: WorkspaceSetupSnapshot): WorkspaceSetupSnapshot["status"] | null { + if ( + snapshot.status === "failed" || + snapshot.status === "blocked" || + snapshot.error !== null || + snapshot.detail.commands.length > 0 + ) + return snapshot.status; + return null; + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 82f0113bd5..02b1625228 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -164,7 +164,7 @@ export type CreatePaseoWorktreeWorkflowFn = ( interface HandleWorkspaceSetupStatusRequestDependencies { emit: EmitSessionMessage; - workspaceSetupSnapshots: ReadonlyMap; + workspaceSetupSnapshots: Pick, "get">; getWorkspace: (workspaceId: string) => Promise; } diff --git a/packages/website/e2e/i18n.spec.ts b/packages/website/e2e/i18n.spec.ts index 1959dc455e..d235c4209e 100644 --- a/packages/website/e2e/i18n.spec.ts +++ b/packages/website/e2e/i18n.spec.ts @@ -1,10 +1,12 @@ import { expect, test, type Page } from "playwright/test"; +function languageButton(page: Page) { + return page.getByRole("button", { name: /^(Language|语言)/ }).first(); +} + async function chooseLanguage(page: Page, language: string) { - await page - .getByRole("combobox", { name: /^(Language|语言)$/ }) - .first() - .selectOption({ label: language }); + await languageButton(page).click(); + await page.getByRole("menuitemradio", { name: language }).click(); } function describeAlternates(links: Element[]): string[] { @@ -62,6 +64,14 @@ test.describe("server-rendered Chinese pages", () => { await expect(mockup).toContainText("给 Agent 发消息"); await expect(mockup).not.toContainText("Ready to review"); }); + + test("keep no space after full-width punctuation in the homepage subtitle", async ({ page }) => { + await page.goto("/zh-CN"); + + await expect(page.getByText(/^同时运行多个编程 Agent/)).toHaveText( + "同时运行多个编程 Agent,在任何机器上。在桌前,也在手机上。", + ); + }); }); test("switches language on the same page, keeping the query and then every link", async ({ @@ -82,13 +92,65 @@ test("switches language on the same page, keeping the query and then every link" await expect(page.locator("html")).toHaveAttribute("lang", "en"); }); -test("offers the language selector on a phone", async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }); +test("shows the language as an abbreviation and lists every language by name", async ({ page }) => { + await page.goto("/docs/cli#running-agents"); + const button = languageButton(page); + await expect(button).toHaveText("EN"); + await expect(button).toHaveAccessibleName("Language: English"); + + await button.click(); + await expect(page.getByRole("menuitemradio", { name: "English" })).toHaveAttribute( + "aria-checked", + "true", + ); + const chinese = page.getByRole("menuitemradio", { name: /简体中文/ }); + await expect(chinese).toHaveAttribute("aria-checked", "false"); + await expect(page.getByRole("menu")).toBeInViewport({ ratio: 1 }); + await expect(chinese).toHaveAttribute("href", "/zh-CN/docs/cli#running-agents"); + + await page.mouse.click(5, 400); + await expect(page.getByRole("menu")).toHaveCount(0); +}); + +test("switches language from the keyboard", async ({ page }) => { await page.goto("/"); - await chooseLanguage(page, "简体中文"); + await languageButton(page).focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("menuitemradio", { name: "English" })).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu")).toHaveCount(0); + await expect(languageButton(page)).toBeFocused(); + + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + await expect(page.getByRole("menuitemradio", { name: /简体中文/ })).toBeFocused(); + await page.keyboard.press("Enter"); await expect(page).toHaveURL(/\/zh-CN$/); - await page.screenshot({ path: test.info().outputPath("mobile-zh-home.png") }); + await expect(languageButton(page)).toHaveText("ZH"); + await expect(languageButton(page)).toBeFocused(); +}); + +test.describe("on a phone", () => { + test.use({ viewport: { width: 390, height: 844 }, hasTouch: true }); + + const cases = [ + { from: "/", choose: "简体中文", to: "/zh-CN", abbreviation: "ZH" }, + { from: "/zh-CN", choose: "English", to: "/", abbreviation: "EN" }, + { from: "/docs/cli", choose: "简体中文", to: "/zh-CN/docs/cli", abbreviation: "ZH" }, + ]; + for (const { from, choose, to, abbreviation } of cases) { + test(`switches from ${from} to ${to} with taps`, async ({ page, baseURL }) => { + await page.goto(from); + await languageButton(page).tap(); + await expect(page.getByRole("menu")).toBeInViewport({ ratio: 1 }); + await page.getByRole("menuitemradio", { name: choose }).tap(); + + await expect(page).toHaveURL(new URL(to, baseURL).href); + await expect(languageButton(page)).toHaveText(abbreviation); + }); + } }); test("finds plugins by their Chinese and English listing text", async ({ page }) => { diff --git a/packages/website/package.json b/packages/website/package.json index 744e296f15..77cb53a0da 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.12.0-beta.3", + "version": "0.12.0", "private": true, "type": "module", "scripts": { diff --git a/packages/website/src/components/language-selector.tsx b/packages/website/src/components/language-selector.tsx index 694341d0a5..9fb219fad5 100644 --- a/packages/website/src/components/language-selector.tsx +++ b/packages/website/src/components/language-selector.tsx @@ -1,55 +1,249 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; -import { Languages } from "lucide-react"; -import { useCallback } from "react"; +import { AnimatePresence, motion, type Transition } from "framer-motion"; +import { Check, ChevronDown, Languages } from "lucide-react"; +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { - isLocale, + LANGUAGE_ABBREVIATIONS, LANGUAGE_NAMES, LOCALES, + type Locale, localizePath, splitLocalePath, useLocale, } from "~/i18n"; +const MENU_HIDDEN = { opacity: 0, y: -4 }; +const MENU_SHOWN = { opacity: 1, y: 0 }; +const MENU_TRANSITION: Transition = { duration: 0.15, ease: "easeOut" }; + +const ITEM_SELECTOR = '[role="menuitemradio"]'; + +/** The closest the menu comes to the edge of the screen. */ +const VIEWPORT_MARGIN = 8; + +/** Which item takes focus when the menu opens: the current language, or an end the arrow key named. */ +type OpenFocus = "current" | "first" | "last"; + /** * Switches the page to the same page in another language, keeping its query and hash so filters * and anchors survive. The URL carries the choice, so every link on the new page stays in it. + * + * The trigger shows the language abbreviated; the menu names every language in itself. It follows + * the menu button pattern: arrows move between languages, Escape and outside presses close it. */ export function LanguageSelector() { const { t } = useTranslation(); const locale = useLocale(); const location = useLocation(); const navigate = useNavigate(); - const onChange = useCallback( - (event: React.ChangeEvent) => { - const next = event.target.value; - if (!isLocale(next) || next === locale) return; - const { path } = splitLocalePath(location.pathname); - const hash = location.hash ? `#${location.hash}` : ""; - void navigate({ href: `${localizePath(path, next)}${location.searchStr}${hash}` }); + const menuId = useId(); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const [openFocus, setOpenFocus] = useState(null); + const open = openFocus !== null; + + const close = useCallback(() => setOpenFocus(null), []); + + // The menu hangs from the trigger's end, which on a narrow header can push it off the screen. + useLayoutEffect(() => { + if (open && menuRef.current) keepInViewport(menuRef.current); + }, [open]); + + useEffect(() => { + if (!openFocus) return; + focusOnOpen(menuItems(menuRef.current), openFocus); + }, [openFocus]); + + useEffect(() => { + if (!open) return; + function closeOnOutsidePress(event: PointerEvent) { + if (!rootRef.current?.contains(event.target as Node)) close(); + } + document.addEventListener("pointerdown", closeOnOutsidePress); + return () => document.removeEventListener("pointerdown", closeOnOutsidePress); + }, [open, close]); + + const toggle = useCallback(() => setOpenFocus((current) => (current ? null : "current")), []); + + const onTriggerKeyDown = useCallback((event: React.KeyboardEvent) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + setOpenFocus(event.key === "ArrowDown" ? "first" : "last"); + } + }, []); + + const onMenuKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const items = menuItems(menuRef.current); + const index = items.indexOf(document.activeElement as HTMLElement); + const moveTo = (next: number) => { + event.preventDefault(); + items[(next + items.length) % items.length]?.focus(); + }; + switch (event.key) { + case "ArrowDown": + return moveTo(index + 1); + case "ArrowUp": + return moveTo(index - 1); + case "Home": + return moveTo(0); + case "End": + return moveTo(items.length - 1); + case " ": + event.preventDefault(); + items[index]?.click(); + return; + case "Escape": + event.preventDefault(); + close(); + triggerRef.current?.focus(); + return; + case "Tab": + close(); + return; + } }, - [locale, location.hash, location.pathname, location.searchStr, navigate], + [close], ); + + const { path } = splitLocalePath(location.pathname); + const suffix = `${location.searchStr}${location.hash ? `#${location.hash}` : ""}`; + + const choose = useCallback( + (href: string) => { + close(); + triggerRef.current?.focus(); + void navigate({ href }); + }, + [close, navigate], + ); + return ( - + {LOCALES.map((option) => ( + + ))} + + )} + + ); } + +/** + * One language in the menu. It is a real link to the same page in that language, so it can be + * opened in a new tab; a plain click switches in place. + */ +function LanguageOption({ + language, + href, + checked, + onChoose, +}: { + language: Locale; + href: string; + checked: boolean; + onChoose: (href: string) => void; +}) { + const { t } = useTranslation(); + const localName = t(`languageSelector.names.${language}`); + const onClick = useCallback( + (event: React.MouseEvent) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + event.preventDefault(); + onChoose(href); + }, + [href, onChoose], + ); + const focusOnHover = useCallback( + (event: React.MouseEvent) => event.currentTarget.focus(), + [], + ); + return ( + + + {LANGUAGE_NAMES[language]} + {localName !== LANGUAGE_NAMES[language] && ( + {localName} + )} + + + ); +} + +function menuItems(menu: HTMLElement | null): HTMLElement[] { + return menu ? [...menu.querySelectorAll(ITEM_SELECTOR)] : []; +} + +function keepInViewport(menu: HTMLElement) { + const { left, right } = menu.getBoundingClientRect(); + let shift = 0; + if (left < VIEWPORT_MARGIN) shift = VIEWPORT_MARGIN - left; + else if (right > window.innerWidth - VIEWPORT_MARGIN) { + shift = window.innerWidth - VIEWPORT_MARGIN - right; + } + menu.style.translate = `${shift}px 0`; +} + +function focusOnOpen(items: HTMLElement[], openFocus: OpenFocus) { + if (openFocus === "first") items[0]?.focus(); + else if (openFocus === "last") items.at(-1)?.focus(); + else items.find((item) => item.getAttribute("aria-checked") === "true")?.focus(); +} diff --git a/packages/website/src/i18n/index.ts b/packages/website/src/i18n/index.ts index 8e16f3cb49..caafd03179 100644 --- a/packages/website/src/i18n/index.ts +++ b/packages/website/src/i18n/index.ts @@ -1,5 +1,6 @@ export { DEFAULT_LOCALE, + LANGUAGE_ABBREVIATIONS, LANGUAGE_NAMES, OPEN_GRAPH_LOCALES, LOCALES, diff --git a/packages/website/src/i18n/locale.ts b/packages/website/src/i18n/locale.ts index ecf8ef65a8..138877e803 100644 --- a/packages/website/src/i18n/locale.ts +++ b/packages/website/src/i18n/locale.ts @@ -12,6 +12,12 @@ export const LANGUAGE_NAMES: Record = { "zh-CN": "简体中文", }; +/** Each language abbreviated, for the closed language selector. */ +export const LANGUAGE_ABBREVIATIONS: Record = { + en: "EN", + "zh-CN": "ZH", +}; + /** `og:locale` values, which use an underscore and a region. */ export const OPEN_GRAPH_LOCALES: Record = { en: "en_US", diff --git a/packages/website/src/i18n/resources/en.json b/packages/website/src/i18n/resources/en.json index 70021a54f6..055229ca47 100644 --- a/packages/website/src/i18n/resources/en.json +++ b/packages/website/src/i18n/resources/en.json @@ -13,7 +13,12 @@ "backHome": "Go to the homepage" }, "languageSelector": { - "label": "Language" + "label": "Language", + "current": "Language: {{language}}", + "names": { + "en": "English", + "zh-CN": "Simplified Chinese" + } }, "nav": { "plugins": "Plugins", diff --git a/packages/website/src/i18n/resources/zh-CN.json b/packages/website/src/i18n/resources/zh-CN.json index 2569626e34..048d351e4d 100644 --- a/packages/website/src/i18n/resources/zh-CN.json +++ b/packages/website/src/i18n/resources/zh-CN.json @@ -13,7 +13,12 @@ "backHome": "返回首页" }, "languageSelector": { - "label": "语言" + "label": "语言", + "current": "语言:{{language}}", + "names": { + "en": "英语", + "zh-CN": "简体中文" + } }, "nav": { "plugins": "插件", @@ -322,7 +327,7 @@ "metaTitle": "Paseo – 随时随地运行 Claude Code、Codex、Copilot、OpenCode", "metaDescription": "面向 Claude Code、Codex、OpenCode、Pi 以及另外 30 多个编程 Agent 的开源应用。在你自己的机器上并行运行多个 Agent。编辑器、终端、diff、拉取请求和浏览器集于一个窗口。桌面端一次下载即可使用,iOS 和 Android 上提供完整应用。", "title": "面向 Agent 的
开发环境", - "subtitle": "同时运行多个编程 Agent,在任何机器上。
在桌前,也在手机上。" + "subtitle": "同时运行多个编程 Agent,在任何机器上。
在桌前,也在手机上。" }, "agents": { "metaTitle": "支持的 Agent – Paseo 可运行的所有编程 Agent", diff --git a/packages/website/src/schedule-docs.test.ts b/packages/website/src/schedule-docs.test.ts new file mode 100644 index 0000000000..015d85fe6c --- /dev/null +++ b/packages/website/src/schedule-docs.test.ts @@ -0,0 +1,16 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; + +const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs"); + +it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])( + "%s makes schedule run retention discoverable", + async (file) => { + const markdown = await readFile(path.join(docsRoot, file), "utf8"); + expect(markdown).toContain("Archive on finish"); + if (file !== "schedules.md") { + expect(markdown).toContain("/docs/schedules#keep-run-workspaces"); + } + }, +); diff --git a/patches/react-native-gesture-handler+2.28.0.patch b/patches/react-native-gesture-handler+2.32.0.patch similarity index 79% rename from patches/react-native-gesture-handler+2.28.0.patch rename to patches/react-native-gesture-handler+2.32.0.patch index ada7b3a511..af0941ac46 100644 --- a/patches/react-native-gesture-handler+2.28.0.patch +++ b/patches/react-native-gesture-handler+2.32.0.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt -index a532cf6..7b66222 100644 +index 8d78aea..02e3ea6 100644 --- a/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -2,6 +2,7 @@ package com.swmansion.gesturehandler.core @@ -10,7 +10,7 @@ index a532cf6..7b66222 100644 import android.view.MotionEvent import android.view.View import android.view.ViewGroup -@@ -24,6 +25,10 @@ class GestureHandlerOrchestrator( +@@ -28,6 +29,10 @@ class GestureHandlerOrchestrator( private val awaitingHandlers = arrayListOf() private val preparedHandlers = arrayListOf() @@ -21,7 +21,7 @@ index a532cf6..7b66222 100644 // In `onHandlerStateChange` method we iterate through `awaitingHandlers`, but calling `tryActivate` may modify this list. // To avoid `ConcurrentModificationException` we iterate through copy. There is one more problem though - if handler was // removed from `awaitingHandlers`, it was still present in copy of original list. This hashset helps us identify which handlers -@@ -42,6 +47,7 @@ class GestureHandlerOrchestrator( +@@ -46,6 +51,7 @@ class GestureHandlerOrchestrator( fun onTouchEvent(event: MotionEvent): Boolean { isHandlingTouch = true val action = event.actionMasked @@ -29,9 +29,9 @@ index a532cf6..7b66222 100644 if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN || action == MotionEvent.ACTION_HOVER_MOVE -@@ -562,6 +568,107 @@ class GestureHandlerOrchestrator( - extractGestureHandlers(wrapperView, tempCoords, pointerId, event) - } +@@ -579,6 +585,107 @@ class GestureHandlerOrchestrator( + private fun shouldIgnoreSubtreeIfGestureHandlerRootView(view: View) = + view is RNGestureHandlerRootView && view != wrapperView && view.isRootViewEnabled() + private fun trackPointerDownPoints(event: MotionEvent) { + val index = event.actionIndex @@ -138,7 +138,7 @@ index a532cf6..7b66222 100644 viewGroup: ViewGroup, coords: FloatArray, diff --git a/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt b/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt -index d8f8fd6..ed101b9 100644 +index 629c155..e1909ff 100644 --- a/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt +++ b/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt @@ -19,6 +19,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: @@ -149,9 +149,9 @@ index d8f8fd6..ed101b9 100644 private var passingTouch = false init { -@@ -117,6 +118,14 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: +@@ -118,6 +119,14 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: passingTouch = true - orchestrator!!.onTouchEvent(ev) + orchestrator!!.onTouchEvent(event) passingTouch = false + + // On the transition into interception, cancel the native views the pointers landed on - the @@ -164,6 +164,21 @@ index d8f8fd6..ed101b9 100644 return shouldIntercept } +diff --git a/node_modules/react-native-gesture-handler/lib/commonjs/web/handlers/GestureHandler.js b/node_modules/react-native-gesture-handler/lib/commonjs/web/handlers/GestureHandler.js +index 96c5227..cbf1a88 100644 +--- a/node_modules/react-native-gesture-handler/lib/commonjs/web/handlers/GestureHandler.js ++++ b/node_modules/react-native-gesture-handler/lib/commonjs/web/handlers/GestureHandler.js +@@ -58,7 +58,9 @@ class GestureHandler { + manager.setOnPointerMoveOver(this.onPointerMoveOver.bind(this)); + manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this)); + manager.setOnWheel(this.onWheel.bind(this)); +- manager.registerListeners(); ++ if (this.enabled) { ++ manager.registerListeners(); ++ } + } + + // diff --git a/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js b/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js index 76eb808..7c40526 100644 --- a/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js @@ -184,6 +199,21 @@ index 76eb808..7c40526 100644 } this.markAsOutOfBounds(adaptedEvent.pointerId); this.trackedPointers.delete(adaptedEvent.pointerId); +diff --git a/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js b/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js +index 0791c66..2f41c77 100644 +--- a/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js ++++ b/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js +@@ -52,7 +52,9 @@ export default class GestureHandler { + manager.setOnPointerMoveOver(this.onPointerMoveOver.bind(this)); + manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this)); + manager.setOnWheel(this.onWheel.bind(this)); +- manager.registerListeners(); ++ if (this.enabled) { ++ manager.registerListeners(); ++ } + } + + // diff --git a/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js b/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js index 6ce3b8f..8fe28bc 100644 --- a/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js @@ -204,6 +234,21 @@ index 6ce3b8f..8fe28bc 100644 } this.markAsOutOfBounds(adaptedEvent.pointerId); this.trackedPointers.delete(adaptedEvent.pointerId); +diff --git a/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts b/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts +index ddb79a3..4e1274c 100644 +--- a/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts ++++ b/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts +@@ -79,7 +79,9 @@ export default abstract class GestureHandler implements IGestureHandler { + manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this)); + manager.setOnWheel(this.onWheel.bind(this)); + +- manager.registerListeners(); ++ if (this.enabled) { ++ manager.registerListeners(); ++ } + } + + // diff --git a/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts b/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts index 18d3627..769b0fe 100644 --- a/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts diff --git a/patches/react-native-svg+15.15.3.patch b/patches/react-native-svg+15.15.4.patch similarity index 100% rename from patches/react-native-svg+15.15.3.patch rename to patches/react-native-svg+15.15.4.patch diff --git a/patches/react-native-unistyles+3.2.4.patch b/patches/react-native-unistyles+3.2.4.patch index 25324c6218..0195868340 100644 --- a/patches/react-native-unistyles+3.2.4.patch +++ b/patches/react-native-unistyles+3.2.4.patch @@ -317,3 +317,15 @@ index 39e0d9d..a21ba95 100644 this.stylesCache.clear() this.dependenciesMap.clear() this.disposeListenersMap.clear() +diff --git a/node_modules/react-native-unistyles/android/build.gradle b/node_modules/react-native-unistyles/android/build.gradle +index 195954b..c4ac28c 100644 +--- a/node_modules/react-native-unistyles/android/build.gradle ++++ b/node_modules/react-native-unistyles/android/build.gradle +@@ -38,6 +38,7 @@ if (isNewArchitectureEnabled()) { + + android { + compileSdkVersion safeExtGet('compileSdkVersion', 35) ++ ndkVersion safeExtGet('ndkVersion', '27.1.12297006') + namespace "com.unistyles" + + defaultConfig { diff --git a/plugin-examples/local-plugin/client/icons.ts b/plugin-examples/local-plugin/client/icons.ts new file mode 100644 index 0000000000..553838556a --- /dev/null +++ b/plugin-examples/local-plugin/client/icons.ts @@ -0,0 +1,5 @@ +/** Inline SVG icon. `currentColor` follows the surface's icon color. */ +export const counterIcon = ` + + +`; diff --git a/plugin-examples/local-plugin/index.client.tsx b/plugin-examples/local-plugin/index.client.tsx index 4a95c337fc..e086b50573 100644 --- a/plugin-examples/local-plugin/index.client.tsx +++ b/plugin-examples/local-plugin/index.client.tsx @@ -1,11 +1,12 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { counterIcon } from "./client/icons"; import { contributeClient, ExamplePanel } from "./client/main"; export default function contribute(client: PluginClientContext) { client.addWorkspacePanel({ id: "counter", title: "Plugin counter", - icon: "Blocks", + icon: { svg: counterIcon }, context: "workspace", locations: ["workspace", "explorer"], Component: ExamplePanel, diff --git a/plugins/package.json b/plugins/package.json index 79166d816c..56f39fb45c 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.12.0-beta.3", + "version": "0.12.0", "private": true, "type": "module", "scripts": { diff --git a/public-docs/mcp.md b/public-docs/mcp.md index 58e8a2db56..f90ae2a50b 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -155,6 +155,8 @@ Both use the same cron engine, but they have deliberately different interfaces. MCP heartbeats are ephemeral: create or delete them. To change one, delete it and create a replacement. Pause, resume, update, inspect, logs, and run-once apply to new-agent schedules only. +New-agent run workspaces are archived when each run ends by default. `create_schedule` and `update_schedule` do not expose **Archive on finish** (`archiveOnFinish`); [change the option in the app](/docs/schedules#keep-run-workspaces) to keep subsequent run workspaces active. + ### Agent profiles | Tool | Function | diff --git a/public-docs/mcp.zh-CN.md b/public-docs/mcp.zh-CN.md index 7a429b3f6b..2cb81f4e30 100644 --- a/public-docs/mcp.zh-CN.md +++ b/public-docs/mcp.zh-CN.md @@ -147,6 +147,8 @@ Paseo Agent 默认会收到完成、错误和权限通知;设置 `notifyOnFini MCP heartbeat 是临时的:只能创建或删除。要修改一个 heartbeat,请删除它并创建一个替代的。暂停、恢复、更新、查看、日志和单次运行只适用于新建 Agent 的定时任务。 +默认情况下,新建 Agent 的定时任务每次运行结束时都会归档其工作区。`create_schedule` 和 `update_schedule` 不提供 **Archive on finish**(`archiveOnFinish`);要让之后的运行工作区保持活跃,请[在应用中修改该选项](/docs/schedules#keep-run-workspaces)。 + ### Agent 配置 {#agent-profiles} | 工具 | 功能 | diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index 50d9be9826..b21a637ffa 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -222,7 +222,8 @@ client.addCommandCenterItem({ }); ``` -`icon` is a [Lucide](https://lucide.dev/icons/) icon name. +`icon` is a [Lucide](https://lucide.dev/icons/) icon name, inline SVG, or a component. See +[Icons](/docs/plugins/reference#icons). ## Edit and reload diff --git a/public-docs/plugins/index.zh-CN.md b/public-docs/plugins/index.zh-CN.md index f8bdf569f8..080dd7ab0b 100644 --- a/public-docs/plugins/index.zh-CN.md +++ b/public-docs/plugins/index.zh-CN.md @@ -180,7 +180,7 @@ client.addCommandCenterItem({ }); ``` -`icon` 是一个 [Lucide](https://lucide.dev/icons/) 图标名称。 +`icon` 可以是 [Lucide](https://lucide.dev/icons/) 图标名称、内联 SVG 或组件。参见[图标](/docs/plugins/reference#icons)。 ## 编辑并重新加载 {#edit-and-reload} diff --git a/public-docs/plugins/publishing.md b/public-docs/plugins/publishing.md index 33aec3df99..092a5eaed4 100644 --- a/public-docs/plugins/publishing.md +++ b/public-docs/plugins/publishing.md @@ -51,8 +51,8 @@ Also supply a one-sentence description: use `package.json`'s `description` for n ### Other languages -The website shows listings in English and Simplified Chinese. English is required; translations are -optional. Add translated listing text to `paseo-plugin.json`, keyed by language tag: +Listings show in English and Simplified Chinese. English is required and translations are optional. +Add a translated `name` and `description` to `paseo-plugin.json`, keyed by language tag: ```json { @@ -65,13 +65,20 @@ optional. Add translated listing text to `paseo-plugin.json`, keyed by language } ``` -Each field is optional. A missing translated `name` or `description` shows the English one. +Each field is optional, and a missing one shows the English text. The registry checks +`translations` when it publishes the plugin: keys are canonical language tags other than `en`, and +each value sets only `name` or `description`, as non-empty strings. Paseo itself does not read +`translations`, so a mistake there fails publication, not installation. -To translate the overview, put a whole copy beside `OVERVIEW.md` named for the language, such as -`OVERVIEW.zh-CN.md`, and include it in the published package. The scaffold's `files` list already -includes `OVERVIEW.*.md`. Readers in that language see the translated overview; without one, they -see the English overview with a notice. Keep the translation in step with `OVERVIEW.md`; the overview rules -above apply to it too. +To translate the overview, publish a complete copy named for the language next to `OVERVIEW.md`, +such as `OVERVIEW.zh-CN.md`. The scaffold's `files` list already includes `OVERVIEW.*.md`. The +registry reads `OVERVIEW..md` as a translation when the tag is a canonical language tag with a +two- or three-letter language code, other than `en`. It ignores other names, such as +`OVERVIEW.backup.md` or `OVERVIEW.zh-cn.md`. A three-letter name such as `OVERVIEW.old.md` counts as +a language, so do not use one for a non-translation. Readers in that language see the +translation; others see the English overview with a notice. The overview rules above apply to +translations, so update them when `OVERVIEW.md` changes. Reviewers can add or correct translations +in the registry. ## Icons and screenshots diff --git a/public-docs/plugins/publishing.zh-CN.md b/public-docs/plugins/publishing.zh-CN.md index b388df4969..fe9c231e12 100644 --- a/public-docs/plugins/publishing.zh-CN.md +++ b/public-docs/plugins/publishing.zh-CN.md @@ -35,7 +35,7 @@ nav: 发布 ### 其他语言 {#other-languages} -网站以英文和简体中文显示展示页。英文是必需的;翻译是可选的。在 `paseo-plugin.json` 中添加翻译后的展示文本,以语言标签为键: +展示页以英文和简体中文显示。英文是必需的,翻译是可选的。在 `paseo-plugin.json` 中添加翻译后的 `name` 和 `description`,以语言标签为键: ```json { @@ -48,9 +48,9 @@ nav: 发布 } ``` -每个字段都是可选的。缺少翻译后的 `name` 或 `description` 时,会显示英文的值。 +每个字段都是可选的,缺少的字段会显示英文。注册表在发布插件时检查 `translations`:键必须是除 `en` 之外的标准语言标签,每个值只能设置 `name` 或 `description`,且必须是非空字符串。Paseo 本身不读取 `translations`,因此这里的错误会导致发布失败,而不会影响安装。 -要翻译概览,请在 `OVERVIEW.md` 旁边放一份以语言命名的完整副本,例如 `OVERVIEW.zh-CN.md`,并把它包含在发布的包中。脚手架的 `files` 列表已经包含 `OVERVIEW.*.md`。使用该语言的读者会看到翻译后的概览;没有翻译时,他们会看到英文概览和一条提示。请让翻译与 `OVERVIEW.md` 保持同步;上面的概览规则同样适用于它。 +要翻译概览,请在 `OVERVIEW.md` 旁边发布一份以语言命名的完整副本,例如 `OVERVIEW.zh-CN.md`。脚手架的 `files` 列表已经包含 `OVERVIEW.*.md`。当标签是语言代码为两个或三个字母、且不是 `en` 的标准语言标签时,注册表会把 `OVERVIEW.<标签>.md` 当作翻译,并忽略 `OVERVIEW.backup.md`、`OVERVIEW.zh-cn.md` 这类其他名称。`OVERVIEW.old.md` 这样的三字母名称会被当作一种语言,因此不要用它命名非翻译文件。使用该语言的读者会看到翻译;其他读者会看到英文概览和一条提示。上面的概览规则同样适用于翻译,因此 `OVERVIEW.md` 变更时请同步更新翻译。审查者可以在注册表中补充或修正翻译。 ## 图标和截图 {#icons-and-screenshots} diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 6e48a05b3f..06f55d1f8c 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -52,16 +52,16 @@ The required root manifest is `paseo-plugin.json`: } ``` -| Field | Required | Behavior | -| -------------- | -------- | ----------------------------------------------------------------------------------------- | -| `id` | Yes | Default installation ID. | -| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. | -| `icon` | No | Relative path to a PNG inside the plugin package. | -| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. | -| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | -| `translations` | No | Listing `name` and `description` in other languages, keyed by language tag. | -| `requirements` | No | Supported Paseo versions, described below. | -| `build` | No | Preparation commands, described in the CLI reference. | +| Field | Required | Behavior | +| -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Yes | Default installation ID. | +| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. | +| `icon` | No | Relative path to a PNG inside the plugin package. | +| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. | +| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | +| `translations` | No | Listing text in other languages, read by the registry and ignored by Paseo. See [other languages](/docs/plugins/publishing#other-languages). | +| `requirements` | No | Supported Paseo versions, described below. | +| `build` | No | Preparation commands, described in the CLI reference. | Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..` segments. Registry listings for visual plugins require an interface image even though `media` is @@ -74,10 +74,6 @@ Unknown top-level fields are ignored. Known fields still validate, and unknown k Manifests using `name`, `icon`, or `media` require Paseo 0.11.0 or later; older daemons reject these fields during installation. -`translations` keys are canonical language tags other than `en`, such as `zh-CN`. Each value may -set `name`, `description`, or both, as non-empty strings; other keys are rejected. See -[other languages](/docs/plugins/publishing#other-languages) for how listings use them. - ### Requirements `requirements` is an optional object. Its currently supported key, `paseo`, accepts an npm semver @@ -242,7 +238,7 @@ guarantee compatibility with another host's renderer. These exact module specifiers use the host's runtime instances. A client bundle that requests another host module fails with `Module "" is not available in plugin client code`. -Do not import `lucide-react-native`, `react-native-svg`, or DOM libraries. Set contribution `icon` fields to a [Lucide icon name](https://lucide.dev/icons/); Paseo validates the name and renders the icon. +Do not import `lucide-react-native`, `react-native-svg`, or DOM libraries. Set contribution `icon` fields to a [Lucide icon name](https://lucide.dev/icons/), inline SVG, or a component; Paseo validates the icon and renders it. See [Icons](#icons). ### Cross-platform rules @@ -418,11 +414,9 @@ Use `runAcpProvider()` from `@getpaseo/plugin/server/acp` to adapt a command-bac hooks only for a vendor's discovery, configuration, notification, or tool-call differences. `ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`. -It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be -self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and -external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are -allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline -SVG or URL. +It must resolve inside that directory to a regular file that follows the +[SVG icon rules](#svg-rules). Paseo reads and validates the file when the plugin starts; the string +is never inline SVG or a URL. ### Usage sources @@ -596,7 +590,7 @@ bypasses that cache. Entries carry `id`, `account.label`, and `fetchedAt`. Faile on `server_info.features.usageSources`. The older `provider.usage.list` RPC maps the same reports for 0.10 clients and renders problems into its `error` string. -The source icon follows the provider SVG restrictions above. +The source icon is a file path with the same rules as a provider icon. ## Entry point and cleanup @@ -1048,10 +1042,10 @@ closes the popover. Render the item with the sidebar kit from `@getpaseo/plugin/client/ui`: -| Component | Props and behavior | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SidebarRow` | Required `onPress`; optional `icon` (Lucide name or `{ size, color }` component), `label`, `active`, `trailing`, `id`. A full-width row. `trailing` renders beside the row's pressable, so a button in it presses on its own; a press elsewhere in it presses the row. `id` tells rows of one item apart in test IDs. | -| `SidebarSeparator` | No props. The sidebar's separator line, between groups of rows. | +| Component | Props and behavior | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SidebarRow` | Required `onPress`; optional `icon` (any [icon](#icons) form), `label`, `active`, `trailing`, `id`. A full-width row. `trailing` renders beside the row's pressable, so a button in it presses on its own; a press elsewhere in it presses the row. `id` tells rows of one item apart in test IDs. | +| `SidebarSeparator` | No props. The sidebar's separator line, between groups of rows. | A popover anchors to the row that was pressed, whether the press landed on the row or on its `trailing` content, so a per-row "More" button opens its popover next to its own row. @@ -1326,14 +1320,82 @@ Showing another toast replaces the currently visible toast. An empty message is ### Icons -`Icon` renders a [Lucide icon](https://lucide.dev/icons/) from Paseo's installed icon set. Plugin bundles do not import -`lucide-react-native` or `react-native-svg`. +Every contribution `icon` field takes one of three forms, typed `PluginIcon` in +`@getpaseo/plugin/client`: -| Prop | Type | Required | Behavior | -| ------- | -------- | -------- | ----------------------------------------------- | -| `name` | `string` | Yes | Lucide icon name. Unknown names render nothing. | -| `size` | `number` | No | Icon width and height. | -| `color` | `string` | No | Icon color. Use a plugin theme token. | +| Form | Example | Use it for | +| ----------- | --------------------------- | --------------------------------------------------- | +| Lucide name | `icon: "Scan"` | Any [Lucide icon](https://lucide.dev/icons/). | +| Inline SVG | `icon: { svg: reviewIcon }` | A brand mark or an icon Lucide does not have. | +| Component | `icon: ReviewBadge` | An icon that changes with state, such as a counter. | + +Attachment sources take a Lucide name or inline SVG (`PluginSerializableIcon`), because each +attached item stores its source's icon. + +Paseo validates the icon when the contribution registers. An unknown Lucide name or an SVG that +breaks a rule fails the plugin with the reason: + +```text +Workspace panel review has an invalid icon: script elements are not allowed +``` + +Paseo draws inline SVG at the surface's icon size and passes the surface's icon color as +`currentColor`. Paint with `currentColor` to follow the theme; fixed fills keep their colors. + +A component receives `PluginIconProps`, `{ size, color }`, and renders within that size. Header +buttons and composer pills pass [`PluginButtonIconProps`](#custom-icons-and-popover-content). + +#### SVG rules + +Inline SVG, provider icons, and usage-source icons follow the same rules: + +- One `` document, no larger than 64 KiB. +- No `