fix(plugins): keep both process lifecycle regressions when updating main

Preserve the idle-ping fix and current main's invalid-message cleanup test;
both were inserted at the same location and cover distinct process exits.

Co-authored-by: Brandon Elliott <923787+Brandon168@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-11-11 05:58:47 +00:00
co-authored by Brandon Elliott
141 changed files with 4978 additions and 1522 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
filters: .github/ci-paths.yml
- name: Validate CI contracts
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/changelog-utils.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/sync-release-notes-from-changelog.test.mjs scripts/upload-release-assets.test.mjs
quality:
name: quality
+125
View File
@@ -1,5 +1,130 @@
# Changelog
## 0.16.0-beta.1 - 2026-11-10
- **Background tasks:** Paseo now shows the background tasks an agent is running, such as shells and monitors, above the composer, with a Stop button for each. Works with Claude Code, Codex and OpenCode v2. ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162) by [@fxlelouarn](https://github.com/fxlelouarn))
- **Plugin sidebar views:** A plugin can replace the sidebar's content with its own layout of workspaces, agents, projects and terminals across every connected host. Pick the view per device under Settings → Sidebar; Paseo's sidebar header and footer stay in place. ([#8906](https://github.com/getpaseo/paseo/pull/8906))
- **Plugin composer controls:** Plugins can add their own controls to the composer, and read and set its prompt, attachments, provider, model, mode and thinking. They can also put the cursor in any agent's composer, add items to New workspace, and store their own metadata on agents and workspaces. ([#8744](https://github.com/getpaseo/paseo/pull/8744), [#9209](https://github.com/getpaseo/paseo/pull/9209), [#9207](https://github.com/getpaseo/paseo/pull/9207))
- **Faster diff loading:** Opening Changes shows the file tree right away while file contents load. Large diffs arrive in batches, so the rest of the app keeps responding while they load, and diffs take priority over the daemon's background worktree scans. ([#8907](https://github.com/getpaseo/paseo/pull/8907), [#9241](https://github.com/getpaseo/paseo/pull/9241), [#9243](https://github.com/getpaseo/paseo/pull/9243))
- **Polish:** The chat stays still when a turn ends, and returning to a chat that has not changed shows it as it is with no refresh. Tool rows show their final title and icon from the first frame, the context window popover opens with usage already loaded, and sidebar hover cards open one at a time. ([#8944](https://github.com/getpaseo/paseo/pull/8944), [#9175](https://github.com/getpaseo/paseo/pull/9175), [#9242](https://github.com/getpaseo/paseo/pull/9242), [#9248](https://github.com/getpaseo/paseo/pull/9248), [#9212](https://github.com/getpaseo/paseo/pull/9212), [#9071](https://github.com/getpaseo/paseo/pull/9071))
- **Rebuilt mobile chat timeline:** The chat timeline on iOS and Android was rebuilt. Expanding or collapsing a turn summary, todo card or tool-call group keeps the line you tapped in place, and on Android so do Show more, Show less and plan cards. ([#8717](https://github.com/getpaseo/paseo/pull/8717), [#9085](https://github.com/getpaseo/paseo/pull/9085))
- **Keyboard shortcuts on mobile:** With a keyboard connected, iPad and Android now run the same shortcuts as desktop, including your custom bindings. Shortcut hints and Settings → Shortcuts appear when a keyboard is detected. Shortcuts also work in narrow desktop and browser windows. ([#8625](https://github.com/getpaseo/paseo/pull/8625) by [@jeroenfrenken](https://github.com/jeroenfrenken), [@lstroud](https://github.com/lstroud))
- **Touch-sized tablet controls:** On iPad and other touch-screen devices, the wide layout now has touch-sized buttons, rows and bars. The composer toolbar folds its controls into the model menu when space runs out, and swiping right on the workspace reveals a collapsed sidebar as an overlay. ([#8748](https://github.com/getpaseo/paseo/pull/8748), [#8885](https://github.com/getpaseo/paseo/pull/8885), [#8891](https://github.com/getpaseo/paseo/pull/8891), [#8909](https://github.com/getpaseo/paseo/pull/8909), [#9151](https://github.com/getpaseo/paseo/pull/9151), [#9238](https://github.com/getpaseo/paseo/pull/9238), [#8739](https://github.com/getpaseo/paseo/pull/8739) by [@zeyugao](https://github.com/zeyugao))
- **Undo workspace archive:** Archiving a workspace in the app now shows a "Workspace archived" toast for six seconds. Undo, or Cmd/Ctrl+Z, restores the workspace with its tabs and agents. On desktop, sidebar workspace rows also get an archive button on hover. ([#8904](https://github.com/getpaseo/paseo/pull/8904), [#9104](https://github.com/getpaseo/paseo/pull/9104))
- **Fewer reconnects on return:** Returning to the app keeps a working connection, including one in the middle of a large response, and reconnects once when it has to. On a host with several saved connections, Paseo stays on the one that works. ([#8651](https://github.com/getpaseo/paseo/pull/8651), [#9086](https://github.com/getpaseo/paseo/pull/9086), [#8855](https://github.com/getpaseo/paseo/pull/8855), [#9116](https://github.com/getpaseo/paseo/pull/9116))
### Added
- Added a Change base picker in Changes for worktree workspaces, so diffs, the commit list and Git actions can use another branch ([#8642](https://github.com/getpaseo/paseo/pull/8642), [#8934](https://github.com/getpaseo/paseo/pull/8934) by [@Myasuka](https://github.com/Myasuka))
- Added links for issue and pull request references such as `#123` and `owner/repo#123` in agent replies, plans, the pull request panel and file previews, for GitHub, GitLab, Gitea, Forgejo and Codeberg ([#8636](https://github.com/getpaseo/paseo/pull/8636))
- Added iPhone Duo layout support: workspace actions sit in the system vertical bar, and the sidebar and workspace split on either side of the fold ([#8741](https://github.com/getpaseo/paseo/pull/8741), [#8901](https://github.com/getpaseo/paseo/pull/8901) by [@zeyugao](https://github.com/zeyugao))
- Added fx as a provider you can add from the provider catalog in Settings → Hosts → Providers ([#9160](https://github.com/getpaseo/paseo/pull/9160))
- Added background agents: an agent or plugin can create a helper with `background: true` that opens no tab, sends no notification and stays out of the sidebar and default agent lists ([#8683](https://github.com/getpaseo/paseo/pull/8683))
- Added syntax highlighting for Terraform and HCL files (`.tf`, `.tfvars`, `.hcl`) ([#9106](https://github.com/getpaseo/paseo/pull/9106))
- Added `PASEO_WORKSPACE_ID` to each agent's environment so an agent can identify, rename or archive its own workspace ([#8939](https://github.com/getpaseo/paseo/pull/8939) by [@DTTerastar](https://github.com/DTTerastar))
### Changed
- Changed opening a workspace to restore the tab you left instead of jumping to the agent that needs attention ([#8935](https://github.com/getpaseo/paseo/pull/8935))
- Changed question cards so picking an option no longer advances automatically; continue with Next or Submit ([#8525](https://github.com/getpaseo/paseo/pull/8525))
- Changed the slash command menu to show loading and error states with Retry, and to stop selecting `/exit` by default ([#8905](https://github.com/getpaseo/paseo/pull/8905))
- Changed the workspace header and tabs to switch to compact controls when Explorer or a split pane narrows the workspace ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088) by [@zeyugao](https://github.com/zeyugao))
- Changed sending a message to a Pi agent whose process has exited to fail immediately with an instruction to reload the agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
### Fixed
- Fixed the working indicator disappearing when returning to a chat whose agent finished while you were away ([#9170](https://github.com/getpaseo/paseo/pull/9170))
- Fixed chats with short collapsed turns opening with empty space until you scrolled ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
- Fixed Collapse turns hiding an earlier reply inside a later turn's summary line after an idle Claude, Codex or OpenCode agent was resumed ([#9098](https://github.com/getpaseo/paseo/pull/9098))
- Fixed the missing space between a prompt and the reply or collapsed-turn summary line below it ([#9009](https://github.com/getpaseo/paseo/pull/9009))
- Fixed Markdown tables showing as literal pipes when written directly below a paragraph with no blank line ([#8485](https://github.com/getpaseo/paseo/pull/8485) by [@ragokan](https://github.com/ragokan), [@suk-6](https://github.com/suk-6), [@thsrite](https://github.com/thsrite))
- Fixed linked badge images that share a line with text rendering as full-width blocks on web and desktop ([#8530](https://github.com/getpaseo/paseo/pull/8530))
- Fixed View diagram opening fullscreen instead of returning to the inline Mermaid diagram after View source ([#8932](https://github.com/getpaseo/paseo/pull/8932) by [@luoph](https://github.com/luoph))
- Fixed mouse-wheel scrolling running in the opposite direction in chats on Android ([#8693](https://github.com/getpaseo/paseo/pull/8693) by [@sjeon87](https://github.com/sjeon87))
- Fixed unsent composer text and attachments being deleted when an agent tab was closed ([#9079](https://github.com/getpaseo/paseo/pull/9079))
- Fixed pasting text with hyperlinks into the composer dropping the link URLs; links now paste as Markdown ([#8972](https://github.com/getpaseo/paseo/pull/8972))
- Fixed the composer losing focus after clicking Send with a mouse ([#9015](https://github.com/getpaseo/paseo/pull/9015))
- Fixed the composer resting 50pt above the bottom edge on iPhone ([#8823](https://github.com/getpaseo/paseo/pull/8823) by [@zeyugao](https://github.com/zeyugao))
- Fixed Add host sending you to Settings from the sidebar and New workspace ([#8512](https://github.com/getpaseo/paseo/pull/8512))
- Fixed a failed agent's red dot and Failed grouping staying in the sidebar after opening the agent or choosing Mark as read ([#8831](https://github.com/getpaseo/paseo/pull/8831))
- Fixed a workspace's unread indicator staying on after it was cleared while the agent's runtime was closed ([#9087](https://github.com/getpaseo/paseo/pull/9087))
- Fixed the context window popover closing by itself whenever the chat scrolled during a streaming reply ([#8742](https://github.com/getpaseo/paseo/pull/8742) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed the sidebar usage summary updating only when Usage was opened ([#7806](https://github.com/getpaseo/paseo/pull/7806) by [@ragokan](https://github.com/ragokan))
- Fixed tooltips and menus above their trigger briefly covering it on Android when their content grew after opening ([#9247](https://github.com/getpaseo/paseo/pull/9247))
- Fixed History showing "Could not load history" after reloading the web app on the History screen ([#9029](https://github.com/getpaseo/paseo/pull/9029))
- Fixed section links in an HTML file preview replacing the preview with the Paseo app instead of scrolling within the file ([#8559](https://github.com/getpaseo/paseo/pull/8559) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed Files Refresh leaving a newly created file hidden when its folder was collapsed during the refresh ([#8582](https://github.com/getpaseo/paseo/pull/8582) by [@hadan8977](https://github.com/hadan8977))
- Fixed the branch switcher showing "No branches found" while branches are still loading or after the request fails ([#8938](https://github.com/getpaseo/paseo/pull/8938))
- Fixed commit history showing a doubled slash, no rename status and no diff for a file moved up a directory ([#8509](https://github.com/getpaseo/paseo/pull/8509) by [@hadan8977](https://github.com/hadan8977))
- Fixed a merged pull request disappearing from its workspace when the checkout was behind the pull request's head
- Fixed workspace Git watchers staying in fallback polling after a watcher setup timeout
- Fixed abandoned connections staying open for up to 45 seconds after the web or desktop app reconnected ([#9067](https://github.com/getpaseo/paseo/pull/9067))
- Fixed a connection routed through another host closing with "The receiving client cannot keep up with host output" while fetching a very large timeline ([#9109](https://github.com/getpaseo/paseo/pull/9109))
- Fixed a connected host staying in error after it was updated to a supported version ([#8423](https://github.com/getpaseo/paseo/pull/8423) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed handshake retries and a once-per-second `relay_e2ee_channel_error` warning continuing after a connection attempt to an unreachable host had timed out ([#9025](https://github.com/getpaseo/paseo/pull/9025), [#9038](https://github.com/getpaseo/paseo/pull/9038))
- Fixed app diagnostics reporting `Last online: never` for a host that had connected and then disconnected ([#9054](https://github.com/getpaseo/paseo/pull/9054), [#9058](https://github.com/getpaseo/paseo/pull/9058))
- Fixed shell commands started by a Codex turn continuing to run after the turn is stopped or interrupted ([#7525](https://github.com/getpaseo/paseo/pull/7525) by [@thomasvan](https://github.com/thomasvan))
- Fixed Claude turns that stop on an API error, such as a session limit or an invalid API key, being reported as completed instead of failed ([#8480](https://github.com/getpaseo/paseo/pull/8480) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed Claude processes staying in memory until the daemon restarted when an idle chat was reopened without sending a prompt ([#9019](https://github.com/getpaseo/paseo/pull/9019) by [@Brandon168](https://github.com/Brandon168))
- Fixed Claude agents not resuming after a graceful daemon stop or restart when agent recovery is turned on ([#8686](https://github.com/getpaseo/paseo/pull/8686) by [@babo-jeonguk](https://github.com/babo-jeonguk))
- Fixed the effort control missing in the composer for Claude models that come from Claude's `settings.json` ([#8699](https://github.com/getpaseo/paseo/pull/8699) by [@ragokan](https://github.com/ragokan), [@yahao123](https://github.com/yahao123))
- Fixed importing a Claude session failing with `Invalid string length` when its transcript is very large ([#8457](https://github.com/getpaseo/paseo/pull/8457) by [@qwist1233-cpu](https://github.com/qwist1233-cpu))
- Fixed Claude turns that end at the maximum-turn limit losing their final token counts and cost ([#8519](https://github.com/getpaseo/paseo/pull/8519))
- Fixed Codex "Apply file changes" approvals showing no file name or diff before you accept ([#8585](https://github.com/getpaseo/paseo/pull/8585) by [@ragokan](https://github.com/ragokan))
- Fixed the timeline showing only the first file's diff when Codex edits several files in one patch ([#8613](https://github.com/getpaseo/paseo/pull/8613) by [@ragokan](https://github.com/ragokan))
- Fixed Codex session discovery and startup intermittently failing with a SQLite initialization error when several Codex operations start at once ([#9008](https://github.com/getpaseo/paseo/pull/9008) by [@colonelpanic8](https://github.com/colonelpanic8), [@michaelmwu](https://github.com/michaelmwu))
- Fixed OpenCode conversations that were mid-turn during a daemon restart showing idle and ignoring Stop ([#8698](https://github.com/getpaseo/paseo/pull/8698))
- Fixed OpenCode being told you declined a tool call when you sent a message while its approval was pending ([#8477](https://github.com/getpaseo/paseo/pull/8477))
- Fixed reopened Pi conversations missing the messages from before a context compaction ([#8523](https://github.com/getpaseo/paseo/pull/8523))
- Fixed archived Pi chats failing to open with `spawn pi ENOENT` after their working folder was removed ([#8442](https://github.com/getpaseo/paseo/pull/8442) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed reading an archived Pi chat leaving a Pi process running when idle runtime cleanup is disabled ([#8554](https://github.com/getpaseo/paseo/pull/8554) by [@rylwin](https://github.com/rylwin))
- Fixed a prompt sent to an ACP agent whose process has exited staying in running forever instead of failing with an error ([#8406](https://github.com/getpaseo/paseo/pull/8406) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed an ACP agent staying stuck on "waiting for permission" after it abandoned an approval request and ended its turn ([#8548](https://github.com/getpaseo/paseo/pull/8548) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed changing the mode of an ACP agent failing with `invalid_params` after the agent sends a config update ([#8912](https://github.com/getpaseo/paseo/pull/8912) by [@a-sh](https://github.com/a-sh))
- Fixed Paseo showing and saving an unavailable model after selecting it on a running ACP agent ([#8957](https://github.com/getpaseo/paseo/pull/8957))
- Fixed importing a session switching it from its own model to the provider's default model ([#8948](https://github.com/getpaseo/paseo/pull/8948))
- Fixed a message from another agent cancelling the recipient's pending question or approval before you could answer it ([#8453](https://github.com/getpaseo/paseo/pull/8453) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed an agent never being notified of completion after sending a prompt to a closed or archived agent on a connected host ([#8575](https://github.com/getpaseo/paseo/pull/8575))
- Fixed creating a child agent failing with "Caller agent not found" after a daemon restart until the parent chat had been opened ([#8714](https://github.com/getpaseo/paseo/pull/8714))
- Fixed an agent still showing as failed after it resumed from a provider error and completed its next turn ([#8083](https://github.com/getpaseo/paseo/pull/8083) by [@luoph](https://github.com/luoph))
- Fixed a detached subagent still showing under its archived parent until refresh ([#8903](https://github.com/getpaseo/paseo/pull/8903) by [@luoph](https://github.com/luoph))
- Fixed opening an agent failing with "Working directory does not exist" instead of showing its saved conversation ([#8737](https://github.com/getpaseo/paseo/pull/8737))
- Fixed the daemon re-reading all archived agent and workspace history on every workspace update ([#8735](https://github.com/getpaseo/paseo/pull/8735))
- Fixed the daemon repeatedly running Git commands in a workspace folder that was deleted while its file watcher was in fallback polling ([#8462](https://github.com/getpaseo/paseo/pull/8462) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed workspace service connections staying open after the browser tab closes or reloads ([#8421](https://github.com/getpaseo/paseo/pull/8421) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed the daemon web UI answering missing `/.well-known/*` requests with the app page instead of a 404 ([#8622](https://github.com/getpaseo/paseo/pull/8622) by [@HomicideZero](https://github.com/HomicideZero))
- Fixed schedules accepting cron steps after a single number, such as `0/15 * * * *`, and running them hourly; they are now rejected ([#8607](https://github.com/getpaseo/paseo/pull/8607) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed cron schedules that set both day-of-month and day-of-week running only when both matched instead of when either matches ([#8611](https://github.com/getpaseo/paseo/pull/8611) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed `paseo logs --json` printing the plain-text transcript instead of JSON ([#8589](https://github.com/getpaseo/paseo/pull/8589) by [@hadan8977](https://github.com/hadan8977))
- Fixed the `create_agent` tool not telling agents that creating an agent on another provider needs a mode ([#8483](https://github.com/getpaseo/paseo/pull/8483) by [@guerinnoah](https://github.com/guerinnoah))
- Fixed terminals failing to open on Windows machines where Windows PowerShell cannot run `Get-CimInstance` ([#8818](https://github.com/getpaseo/paseo/pull/8818))
- Fixed the terminal typing its version reply into the shell when a program queries the terminal version ([#8419](https://github.com/getpaseo/paseo/pull/8419) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed a finger drag in the web terminal scrolling history about twice as far as the drag ([#8776](https://github.com/getpaseo/paseo/pull/8776) by [@sinomoe](https://github.com/sinomoe))
- Fixed mouse scrolling in terminal programs such as Copilot CLI stopping after switching away from the terminal and back ([#9004](https://github.com/getpaseo/paseo/pull/9004) by [@imink](https://github.com/imink))
- Fixed dropping several files onto a remote terminal pasting their paths with no space between them ([#8946](https://github.com/getpaseo/paseo/pull/8946) by [@sebastianarena](https://github.com/sebastianarena))
- Fixed the `browser_fill` tool reporting success without updating React-controlled inputs and textareas ([#9022](https://github.com/getpaseo/paseo/pull/9022))
- Fixed `browser_evaluate` failing on pages whose Content Security Policy omits `unsafe-eval` ([#8973](https://github.com/getpaseo/paseo/pull/8973) by [@amustapha](https://github.com/amustapha))
- Fixed `browser_logs` omitting the HTTP status of each network request ([#8956](https://github.com/getpaseo/paseo/pull/8956) by [@amustapha](https://github.com/amustapha))
### Plugins
- Added plugin subscriptions, which stream live events from the daemon to a plugin's panels, and binary (`Uint8Array`) payloads for plugin RPCs and events ([#8527](https://github.com/getpaseo/paseo/pull/8527))
- Changed Settings → Plugins to open a plugin's settings from a cog button, with a tab per settings screen ([#8675](https://github.com/getpaseo/paseo/pull/8675))
- Added an upward chevron to plugin composer pills that open a menu or popover ([#8673](https://github.com/getpaseo/paseo/pull/8673))
- Changed daemon startup to launch configured plugins concurrently instead of one after another ([#8427](https://github.com/getpaseo/paseo/pull/8427) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed registry plugin installs failing with "npm artifact changed since review" when the host's npm uses a registry mirror ([#8756](https://github.com/getpaseo/paseo/pull/8756) by [@tlipoca9](https://github.com/tlipoca9))
- Fixed the published `@getpaseo/*` npm packages declaring no license; they now declare Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816) by [@mdlawson](https://github.com/mdlawson), [@ragokan](https://github.com/ragokan))
- Fixed forking from an earlier reply failing with "This checkpoint changed after it was created" after a plugin updated one of its timeline cards ([#8746](https://github.com/getpaseo/paseo/pull/8746) by [@monarchmaisuriya](https://github.com/monarchmaisuriya))
- Fixed Collapse turns leaving a turn open, or folding to an earlier reply, when a plugin renders the final reply ([#8811](https://github.com/getpaseo/paseo/pull/8811) by [@geoqiao](https://github.com/geoqiao))
- Fixed plugin screens showing "This plugin surface is unavailable" while the plugin catalog is still loading after an app reload ([#8123](https://github.com/getpaseo/paseo/pull/8123) by [@vs-tahir](https://github.com/vs-tahir))
- Fixed the Workspaces list being pushed out of the mobile sidebar on short screens when plugins add many navigation items ([#8727](https://github.com/getpaseo/paseo/pull/8727) by [@jaredwang](https://github.com/jaredwang))
- Fixed timeline plugins not receiving the sender, event and original text on agent-message tool calls ([#8492](https://github.com/getpaseo/paseo/pull/8492) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed plugin RPCs failing when their input or output schema uses a transform ([#8550](https://github.com/getpaseo/paseo/pull/8550))
### Notes
- Background tasks, the Change base picker, archive undo, background agents, plugin subscriptions and fx need both the host and the app on this version
## 0.15.4 - 2026-11-10
### Added
+125
View File
@@ -1,5 +1,130 @@
# Changelog
## 0.16.0-beta.1 - 2026-11-10
- **后台任务**:Paseo 现在会在 Composer 上方显示 Agent 正在运行的后台任务,例如 shell 和监视器,每项都带“停止”按钮。支持 Claude Code、Codex 和 OpenCode v2。 ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162),贡献者:[@fxlelouarn](https://github.com/fxlelouarn))
- **插件侧边栏视图**:插件可以用自己的布局替换侧边栏内容,展示所有已连接主机上的工作区、Agent、项目和终端。在“设置 → 侧边栏”中为每台设备选择视图;Paseo 的侧边栏页眉和页脚保持不变。 ([#8906](https://github.com/getpaseo/paseo/pull/8906))
- **插件 Composer 控件**:插件可以向 Composer 添加自己的控件,并读取和设置其中的提示词、附件、Provider、模型、模式和思考强度。插件还可以把光标放到任意 Agent 的 Composer 中、向“新建工作区”添加条目,并在 Agent 和工作区上保存自己的元数据。 ([#8744](https://github.com/getpaseo/paseo/pull/8744), [#9209](https://github.com/getpaseo/paseo/pull/9209), [#9207](https://github.com/getpaseo/paseo/pull/9207))
- **更快的 diff 加载**:打开“更改”时会立即显示文件树,文件内容随后加载。大型 diff 分批到达,加载期间应用的其他部分照常响应,并且 diff 优先于 daemon 的后台 worktree 扫描。 ([#8907](https://github.com/getpaseo/paseo/pull/8907), [#9241](https://github.com/getpaseo/paseo/pull/9241), [#9243](https://github.com/getpaseo/paseo/pull/9243))
- **细节打磨**:一轮对话结束时聊天保持不动,返回没有变化的聊天时直接显示原样,无需刷新。工具行从第一帧起就显示最终标题和图标,上下文窗口浮层打开时用量已加载完成,侧边栏悬停卡片一次只打开一个。 ([#8944](https://github.com/getpaseo/paseo/pull/8944), [#9175](https://github.com/getpaseo/paseo/pull/9175), [#9242](https://github.com/getpaseo/paseo/pull/9242), [#9248](https://github.com/getpaseo/paseo/pull/9248), [#9212](https://github.com/getpaseo/paseo/pull/9212), [#9071](https://github.com/getpaseo/paseo/pull/9071))
- **重建移动端聊天时间线**:iOS 和 Android 上的聊天时间线已重建。展开或折叠轮次摘要、待办卡片或工具调用组时,你点按的那一行保持原位;在 Android 上,“显示更多”“收起”和计划卡片也是如此。 ([#8717](https://github.com/getpaseo/paseo/pull/8717), [#9085](https://github.com/getpaseo/paseo/pull/9085))
- **移动端键盘快捷键**:连接键盘后,iPad 和 Android 现在可使用与桌面端相同的快捷键,包括你自定义的绑定。检测到键盘时会显示快捷键提示和“设置 → 快捷键”。在较窄的桌面端和浏览器窗口中快捷键同样可用。 ([#8625](https://github.com/getpaseo/paseo/pull/8625),贡献者:[@jeroenfrenken](https://github.com/jeroenfrenken)、[@lstroud](https://github.com/lstroud))
- **适合触控的平板控件**:在 iPad 和其他触屏设备上,宽屏布局现在使用适合触控尺寸的按钮、行和栏。Composer 工具栏在空间不足时会把控件收进模型菜单;在工作区上向右滑动,可将已折叠的侧边栏以浮层方式显示。 ([#8748](https://github.com/getpaseo/paseo/pull/8748), [#8885](https://github.com/getpaseo/paseo/pull/8885), [#8891](https://github.com/getpaseo/paseo/pull/8891), [#8909](https://github.com/getpaseo/paseo/pull/8909), [#9151](https://github.com/getpaseo/paseo/pull/9151), [#9238](https://github.com/getpaseo/paseo/pull/9238), [#8739](https://github.com/getpaseo/paseo/pull/8739),贡献者:[@zeyugao](https://github.com/zeyugao))
- **撤销工作区归档**:在应用中归档工作区时,现在会显示“Workspace 已归档”提示,持续六秒。点击“撤销”或按 Cmd/Ctrl+Z 可恢复该工作区及其标签页和 Agent。在桌面端,侧边栏的工作区行在悬停时还会显示归档按钮。 ([#8904](https://github.com/getpaseo/paseo/pull/8904), [#9104](https://github.com/getpaseo/paseo/pull/9104))
- **返回时更少重连**:返回应用时会保留仍可用的连接,包括正在传输大型响应的连接,只在必要时重连一次。主机保存了多个连接时,Paseo 会留在可用的那个连接上。 ([#8651](https://github.com/getpaseo/paseo/pull/8651), [#9086](https://github.com/getpaseo/paseo/pull/9086), [#8855](https://github.com/getpaseo/paseo/pull/8855), [#9116](https://github.com/getpaseo/paseo/pull/9116))
### 新增
- 在“更改”中为 worktree 工作区新增基准分支选择器(Change base),diff、提交列表和 Git 操作可改用其他分支 ([#8642](https://github.com/getpaseo/paseo/pull/8642), [#8934](https://github.com/getpaseo/paseo/pull/8934),贡献者:[@Myasuka](https://github.com/Myasuka))
- 新增 Issue 和 PR 引用链接:Agent 回复、计划、PR 面板和文件预览中的 `#123`、`owner/repo#123` 等引用会链接到 GitHub、GitLab、Gitea、Forgejo 或 Codeberg ([#8636](https://github.com/getpaseo/paseo/pull/8636))
- 新增 iPhone Duo 布局支持:工作区操作位于系统竖向栏中,侧边栏和工作区分列折痕两侧 ([#8741](https://github.com/getpaseo/paseo/pull/8741), [#8901](https://github.com/getpaseo/paseo/pull/8901),贡献者:[@zeyugao](https://github.com/zeyugao))
- 新增 fx Provider,可在“设置 → Hosts → 提供方”的 Provider 目录中添加 ([#9160](https://github.com/getpaseo/paseo/pull/9160))
- 新增后台 Agent:Agent 或插件可通过 `background: true` 创建辅助 Agent,它不打开标签页、不发送通知,也不出现在侧边栏和默认 Agent 列表中 ([#8683](https://github.com/getpaseo/paseo/pull/8683))
- 新增 Terraform 和 HCL 文件(`.tf`、`.tfvars`、`.hcl`)的语法高亮 ([#9106](https://github.com/getpaseo/paseo/pull/9106))
- 在每个 Agent 的环境中新增 `PASEO_WORKSPACE_ID`,Agent 可据此识别、重命名或归档自己的工作区 ([#8939](https://github.com/getpaseo/paseo/pull/8939),贡献者:[@DTTerastar](https://github.com/DTTerastar))
### 变更
- 打开工作区时恢复你离开时的标签页,不再跳转到需要关注的 Agent ([#8935](https://github.com/getpaseo/paseo/pull/8935))
- 问题卡片中选择选项后不再自动前进;请用“下一步”或“提交”继续 ([#8525](https://github.com/getpaseo/paseo/pull/8525))
- 斜杠命令菜单现在显示加载和错误状态并提供“重试”,且不再默认选中 `/exit` ([#8905](https://github.com/getpaseo/paseo/pull/8905))
- 文件浏览器(Explorer)或分屏使工作区变窄时,工作区页眉和标签页改用紧凑控件 ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088),贡献者:[@zeyugao](https://github.com/zeyugao))
- 向进程已退出的 Pi Agent 发送消息时立即失败,并提示重新加载该 Agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
### 修复
- 修复 Agent 在你离开期间完成后,返回聊天时工作指示器消失的问题 ([#9170](https://github.com/getpaseo/paseo/pull/9170))
- 修复包含较短折叠轮次的聊天打开时留有空白、需滚动后才填满的问题 ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
- 修复空闲的 Claude、Codex 或 OpenCode Agent 恢复后,“折叠轮次”把较早的回复藏进后一轮摘要行的问题 ([#9098](https://github.com/getpaseo/paseo/pull/9098))
- 修复提示词与其下方的回复或折叠轮次摘要行之间缺少间距的问题 ([#9009](https://github.com/getpaseo/paseo/pull/9009))
- 修复紧跟在段落之后、中间没有空行的 Markdown 表格显示为原始竖线的问题 ([#8485](https://github.com/getpaseo/paseo/pull/8485),贡献者:[@ragokan](https://github.com/ragokan)、[@suk-6](https://github.com/suk-6)、[@thsrite](https://github.com/thsrite))
- 修复网页版和桌面端中与文字同行的带链接徽章图片被渲染成整行宽度的问题 ([#8530](https://github.com/getpaseo/paseo/pull/8530))
- 修复“查看源码”后点击“查看图表”会打开全屏、而不是回到内联 Mermaid 图表的问题 ([#8932](https://github.com/getpaseo/paseo/pull/8932),贡献者:[@luoph](https://github.com/luoph))
- 修复 Android 上聊天中鼠标滚轮滚动方向相反的问题 ([#8693](https://github.com/getpaseo/paseo/pull/8693),贡献者:[@sjeon87](https://github.com/sjeon87))
- 修复关闭 Agent 标签页时未发送的 Composer 文本和附件被删除的问题 ([#9079](https://github.com/getpaseo/paseo/pull/9079))
- 修复向 Composer 粘贴带超链接的文本时丢失链接地址的问题;链接现在以 Markdown 形式粘贴 ([#8972](https://github.com/getpaseo/paseo/pull/8972))
- 修复用鼠标点击“发送”后 Composer 失去焦点的问题 ([#9015](https://github.com/getpaseo/paseo/pull/9015))
- 修复 iPhone 上 Composer 停在距底部边缘 50pt 处的问题 ([#8823](https://github.com/getpaseo/paseo/pull/8823),贡献者:[@zeyugao](https://github.com/zeyugao))
- 修复从侧边栏和“新建工作区”点击“添加主机”会跳转到“设置”的问题 ([#8512](https://github.com/getpaseo/paseo/pull/8512))
- 修复打开失败的 Agent 或选择 Mark as read 后,其红点和“失败”分组仍留在侧边栏的问题 ([#8831](https://github.com/getpaseo/paseo/pull/8831))
- 修复在 Agent 运行时已关闭的情况下清除未读后,工作区的未读指示仍然亮着的问题 ([#9087](https://github.com/getpaseo/paseo/pull/9087))
- 修复流式回复期间聊天一滚动上下文窗口浮层就自行关闭的问题 ([#8742](https://github.com/getpaseo/paseo/pull/8742),贡献者:[@3ae3ae](https://github.com/3ae3ae))
- 修复侧边栏用量摘要只在打开“使用情况”时才更新的问题 ([#7806](https://github.com/getpaseo/paseo/pull/7806),贡献者:[@ragokan](https://github.com/ragokan))
- 修复 Android 上位于触发元素上方的提示和菜单在打开后内容变多时短暂盖住触发元素的问题 ([#9247](https://github.com/getpaseo/paseo/pull/9247))
- 修复在“历史”页面刷新网页版后显示“无法加载历史”的问题 ([#9029](https://github.com/getpaseo/paseo/pull/9029))
- 修复 HTML 文件预览中的章节链接把预览替换成 Paseo 应用、而不是在文件内滚动的问题 ([#8559](https://github.com/getpaseo/paseo/pull/8559),贡献者:[@3ae3ae](https://github.com/3ae3ae))
- 修复“文件”刷新期间文件夹处于折叠状态时,新建的文件一直不显示的问题 ([#8582](https://github.com/getpaseo/paseo/pull/8582),贡献者:[@hadan8977](https://github.com/hadan8977))
- 修复分支仍在加载或请求失败时,分支切换器显示“没有找到分支。”的问题 ([#8938](https://github.com/getpaseo/paseo/pull/8938))
- 修复文件被移到上一级目录时,提交历史显示双斜杠、没有重命名状态也没有 diff 的问题 ([#8509](https://github.com/getpaseo/paseo/pull/8509),贡献者:[@hadan8977](https://github.com/hadan8977))
- 修复检出落后于 PR 头部时,已合并的 PR 从其工作区中消失的问题
- 修复工作区 Git 监视器在设置超时后一直停留在回退轮询的问题
- 修复网页版或桌面端重连后,被放弃的连接最多保持打开 45 秒的问题 ([#9067](https://github.com/getpaseo/paseo/pull/9067))
- 修复通过另一台主机路由的连接在获取超大时间线时以“The receiving client cannot keep up with host output”关闭的问题 ([#9109](https://github.com/getpaseo/paseo/pull/9109))
- 修复已连接的主机在更新到受支持的版本后仍停留在错误状态的问题 ([#8423](https://github.com/getpaseo/paseo/pull/8423),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复连接不可达主机的尝试超时后,握手重试和每秒一次的 `relay_e2ee_channel_error` 警告仍在继续的问题 ([#9025](https://github.com/getpaseo/paseo/pull/9025), [#9038](https://github.com/getpaseo/paseo/pull/9038))
- 修复应用诊断对曾经连接后又断开的主机报告 `Last online: never` 的问题 ([#9054](https://github.com/getpaseo/paseo/pull/9054), [#9058](https://github.com/getpaseo/paseo/pull/9058))
- 修复 Codex 轮次被停止或中断后,该轮次启动的 shell 命令仍继续运行的问题 ([#7525](https://github.com/getpaseo/paseo/pull/7525),贡献者:[@thomasvan](https://github.com/thomasvan))
- 修复因 API 错误(例如会话限额或 API key 无效)而停止的 Claude 轮次被报告为已完成而非失败的问题 ([#8480](https://github.com/getpaseo/paseo/pull/8480),贡献者:[@RRD-dev](https://github.com/RRD-dev)、[@luoph](https://github.com/luoph))
- 修复重新打开空闲聊天但未发送提示词时,Claude 进程一直留在内存中直到 daemon 重启的问题 ([#9019](https://github.com/getpaseo/paseo/pull/9019),贡献者:[@Brandon168](https://github.com/Brandon168))
- 修复开启 Agent 恢复后,daemon 正常停止或重启时 Claude Agent 不恢复的问题 ([#8686](https://github.com/getpaseo/paseo/pull/8686),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
- 修复来自 Claude `settings.json` 的 Claude 模型在 Composer 中缺少推理强度控件的问题 ([#8699](https://github.com/getpaseo/paseo/pull/8699),贡献者:[@ragokan](https://github.com/ragokan)、[@yahao123](https://github.com/yahao123))
- 修复对话记录非常大时导入 Claude 会话因 `Invalid string length` 失败的问题 ([#8457](https://github.com/getpaseo/paseo/pull/8457),贡献者:[@qwist1233-cpu](https://github.com/qwist1233-cpu))
- 修复在最大轮次限制处结束的 Claude 轮次丢失最终 token 数和费用的问题 ([#8519](https://github.com/getpaseo/paseo/pull/8519))
- 修复 Codex 的“Apply file changes”审批在你接受之前不显示文件名和 diff 的问题 ([#8585](https://github.com/getpaseo/paseo/pull/8585),贡献者:[@ragokan](https://github.com/ragokan))
- 修复 Codex 在一个补丁中编辑多个文件时,时间线只显示第一个文件的 diff 的问题 ([#8613](https://github.com/getpaseo/paseo/pull/8613),贡献者:[@ragokan](https://github.com/ragokan))
- 修复多个 Codex 操作同时启动时,Codex 会话发现和启动偶发 SQLite 初始化错误的问题 ([#9008](https://github.com/getpaseo/paseo/pull/9008),贡献者:[@colonelpanic8](https://github.com/colonelpanic8)、[@michaelmwu](https://github.com/michaelmwu))
- 修复 daemon 重启时正处于轮次中的 OpenCode 对话显示为空闲并忽略“停止”的问题 ([#8698](https://github.com/getpaseo/paseo/pull/8698))
- 修复审批待处理期间你发送消息时,OpenCode 被告知你拒绝了工具调用的问题 ([#8477](https://github.com/getpaseo/paseo/pull/8477))
- 修复重新打开的 Pi 对话缺少上下文压缩之前的消息的问题 ([#8523](https://github.com/getpaseo/paseo/pull/8523))
- 修复工作文件夹被移除后,已归档的 Pi 聊天因 `spawn pi ENOENT` 无法打开的问题 ([#8442](https://github.com/getpaseo/paseo/pull/8442),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复关闭空闲运行时清理的情况下,读取已归档的 Pi 聊天会遗留一个 Pi 进程的问题 ([#8554](https://github.com/getpaseo/paseo/pull/8554),贡献者:[@rylwin](https://github.com/rylwin))
- 修复向进程已退出的 ACP Agent 发送提示词后一直停留在运行中、而不是报错失败的问题 ([#8406](https://github.com/getpaseo/paseo/pull/8406),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复 ACP Agent 放弃审批请求并结束轮次后一直卡在“waiting for permission”的问题 ([#8548](https://github.com/getpaseo/paseo/pull/8548),贡献者:[@3ae3ae](https://github.com/3ae3ae))
- 修复 ACP Agent 发送配置更新后,更改其模式因 `invalid_params` 失败的问题 ([#8912](https://github.com/getpaseo/paseo/pull/8912),贡献者:[@a-sh](https://github.com/a-sh))
- 修复在运行中的 ACP Agent 上选择不可用的模型后,Paseo 仍显示并保存该模型的问题 ([#8957](https://github.com/getpaseo/paseo/pull/8957))
- 修复导入会话时把它从自身的模型切换为 Provider 默认模型的问题 ([#8948](https://github.com/getpaseo/paseo/pull/8948))
- 修复来自其他 Agent 的消息在你作答之前取消接收方待处理的提问或审批的问题 ([#8453](https://github.com/getpaseo/paseo/pull/8453),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复向已连接主机上已关闭或已归档的 Agent 发送提示词后,发送方 Agent 一直收不到完成通知的问题 ([#8575](https://github.com/getpaseo/paseo/pull/8575))
- 修复 daemon 重启后,在打开父聊天之前创建子 Agent 因“Caller agent not found”失败的问题 ([#8714](https://github.com/getpaseo/paseo/pull/8714))
- 修复 Agent 从 Provider 错误中恢复并完成下一轮次后仍显示为失败的问题 ([#8083](https://github.com/getpaseo/paseo/pull/8083),贡献者:[@luoph](https://github.com/luoph))
- 修复已分离的子 Agent 在刷新之前仍显示在其已归档的父 Agent 之下的问题 ([#8903](https://github.com/getpaseo/paseo/pull/8903),贡献者:[@luoph](https://github.com/luoph))
- 修复打开 Agent 时因“Working directory does not exist”失败、而不是显示其已保存对话的问题 ([#8737](https://github.com/getpaseo/paseo/pull/8737))
- 修复 daemon 在每次工作区更新时重新读取全部已归档 Agent 和工作区历史的问题 ([#8735](https://github.com/getpaseo/paseo/pull/8735))
- 修复工作区文件夹在其文件监视器处于回退轮询期间被删除后,daemon 仍反复在其中运行 Git 命令的问题 ([#8462](https://github.com/getpaseo/paseo/pull/8462),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复浏览器标签页关闭或刷新后,工作区服务连接仍保持打开的问题 ([#8421](https://github.com/getpaseo/paseo/pull/8421),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复 daemon 网页界面对不存在的 `/.well-known/*` 请求返回应用页面而非 404 的问题 ([#8622](https://github.com/getpaseo/paseo/pull/8622),贡献者:[@HomicideZero](https://github.com/HomicideZero))
- 修复定时任务接受单个数字后的 cron 步长(例如 `0/15 * * * *`)并按每小时运行的问题;现在会拒绝这类表达式 ([#8607](https://github.com/getpaseo/paseo/pull/8607),贡献者:[@Mar-garet](https://github.com/Mar-garet))
- 修复同时设置了日期和星期的 cron 定时任务只在两者都匹配时运行、而不是任一匹配即运行的问题 ([#8611](https://github.com/getpaseo/paseo/pull/8611),贡献者:[@Mar-garet](https://github.com/Mar-garet))
- 修复 `paseo logs --json` 输出纯文本对话记录而非 JSON 的问题 ([#8589](https://github.com/getpaseo/paseo/pull/8589),贡献者:[@hadan8977](https://github.com/hadan8977))
- 修复 `create_agent` 工具没有告知 Agent 在其他 Provider 上创建 Agent 需要指定模式的问题 ([#8483](https://github.com/getpaseo/paseo/pull/8483),贡献者:[@guerinnoah](https://github.com/guerinnoah))
- 修复在 Windows PowerShell 无法运行 `Get-CimInstance` 的 Windows 机器上终端无法打开的问题 ([#8818](https://github.com/getpaseo/paseo/pull/8818))
- 修复程序查询终端版本时,终端把版本应答输入到 shell 中的问题 ([#8419](https://github.com/getpaseo/paseo/pull/8419),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复在网页版终端中用手指拖动时,历史滚动距离约为拖动距离两倍的问题 ([#8776](https://github.com/getpaseo/paseo/pull/8776),贡献者:[@sinomoe](https://github.com/sinomoe))
- 修复切换离开终端再返回后,Copilot CLI 等终端程序中的鼠标滚动失效的问题 ([#9004](https://github.com/getpaseo/paseo/pull/9004),贡献者:[@imink](https://github.com/imink))
- 修复向远程终端拖入多个文件时,粘贴的路径之间没有空格的问题 ([#8946](https://github.com/getpaseo/paseo/pull/8946),贡献者:[@sebastianarena](https://github.com/sebastianarena))
- 修复 `browser_fill` 工具在未更新 React 受控输入框和文本域的情况下报告成功的问题 ([#9022](https://github.com/getpaseo/paseo/pull/9022))
- 修复 `browser_evaluate` 在内容安全策略未包含 `unsafe-eval` 的页面上失败的问题 ([#8973](https://github.com/getpaseo/paseo/pull/8973),贡献者:[@amustapha](https://github.com/amustapha))
- 修复 `browser_logs` 缺少每个网络请求的 HTTP 状态的问题 ([#8956](https://github.com/getpaseo/paseo/pull/8956),贡献者:[@amustapha](https://github.com/amustapha))
### 插件
- 新增插件订阅,可将 daemon 的实时事件流式推送到插件面板;插件 RPC 和事件支持二进制(`Uint8Array`)负载 ([#8527](https://github.com/getpaseo/paseo/pull/8527))
- “设置 → 插件”改为通过齿轮按钮打开插件设置,每个设置页面对应一个标签页 ([#8675](https://github.com/getpaseo/paseo/pull/8675))
- 为会打开菜单或浮层的插件 Composer 胶囊按钮新增向上的箭头 ([#8673](https://github.com/getpaseo/paseo/pull/8673))
- daemon 启动时改为并发启动已配置的插件,不再逐个启动 ([#8427](https://github.com/getpaseo/paseo/pull/8427),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复主机的 npm 使用镜像源时,从插件库安装插件因“npm artifact changed since review”失败的问题 ([#8756](https://github.com/getpaseo/paseo/pull/8756),贡献者:[@tlipoca9](https://github.com/tlipoca9))
- 修复已发布的 `@getpaseo/*` npm 包未声明许可证的问题;现在声明为 Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816),贡献者:[@mdlawson](https://github.com/mdlawson)、[@ragokan](https://github.com/ragokan))
- 修复插件更新了自己的某张时间线卡片后,从较早的回复分叉因“This checkpoint changed after it was created”失败的问题 ([#8746](https://github.com/getpaseo/paseo/pull/8746),贡献者:[@monarchmaisuriya](https://github.com/monarchmaisuriya))
- 修复由插件渲染最终回复时,“折叠轮次”让轮次保持展开或折叠到较早回复的问题 ([#8811](https://github.com/getpaseo/paseo/pull/8811),贡献者:[@geoqiao](https://github.com/geoqiao))
- 修复应用刷新后插件目录仍在加载时,插件页面显示“This plugin surface is unavailable”的问题 ([#8123](https://github.com/getpaseo/paseo/pull/8123),贡献者:[@vs-tahir](https://github.com/vs-tahir))
- 修复插件添加了许多导航项时,矮屏上的工作区列表被挤出移动端侧边栏的问题 ([#8727](https://github.com/getpaseo/paseo/pull/8727),贡献者:[@jaredwang](https://github.com/jaredwang))
- 修复时间线插件在 Agent 消息工具调用上收不到发送方、事件和原始文本的问题 ([#8492](https://github.com/getpaseo/paseo/pull/8492),贡献者:[@RRD-dev](https://github.com/RRD-dev)、[@luoph](https://github.com/luoph))
- 修复输入或输出 schema 使用 transform 时插件 RPC 失败的问题 ([#8550](https://github.com/getpaseo/paseo/pull/8550))
### 说明
- 后台任务、基准分支选择器、归档撤销、后台 Agent、插件订阅和 fx 需要主机和应用都更新到此版本
## 0.15.4 - 2026-11-10
### 新增
+7 -4
View File
@@ -257,7 +257,7 @@ Workspace archive is a separate lifecycle. Archiving or removing a worktree can
agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the
archived workspace. History navigation must not infer workspace lifecycle from `agent.archivedAt`
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
the route exposes explicit Unarchive and Restore actions.
Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up.
The daemon runs one archive lane per workspace, shared by every connection
@@ -281,9 +281,12 @@ does not flip it back. Tabs and layout are purged only once the undo window clos
archive committed.
History navigation opens the selected agent without changing either archive state. Workspace
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
history hydration. Other archived agents stay archived.
**Unarchive**, **Restore**, Undo, and reopening the directory recover the agents closed by that
workspace archive, including
after a daemon restart or after the toast expires. Agents archived individually beforehand stay
archived. A failed worktree recreation leaves the workspace and its agents archived for retry.
Each recovered agent runs the provider's native unarchive hook before interactive resume and
history hydration.
Opening an agent is a navigation choice, independent of whether its details are cached. The
layout retains that choice across reload while the panel fetches the agent from the daemon.
+13
View File
@@ -39,6 +39,19 @@ The trigger can only stay put when the list has scroll room for the change. When
content fits the viewport, or a collapse at the end would need an offset past the end, the list
ends at its end and the trigger moves by the difference.
## State that survives a remount
The list mounts a row again whenever it opens the chat, brings a web row back into the history
window, or reassigns a native row's LegendList container. State held in the row resets each time.
Keep a disclosure's open state in the per-agent store the timeline provides, keyed by item id:
`ToolCallExpansionContext` for tool calls, `UserMessageExpansionContext` for long user messages.
Decide a row's first painted shape in the commit that mounts it. `MessagePreview` measures its body
in a layout effect, which commits before paint on web and Fabric. `onLayout` lands after the first
frame: a long message measured there paints capped with no fade or show more, then grows by the
control's height a frame later. `onLayout` still tracks later size changes. A zero height comes from
a hidden panel and is ignored.
## Web
`holdRow` pins the row's trigger edge in the reading anchor and runs `apply`; the next reading
+23
View File
@@ -712,6 +712,29 @@ implementations provide it:
in `composer/session.tsx`: `Composer` provides `ComposerInput` (prompt, attachments),
`DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock.
`focus` differs per implementation on purpose. A running agent's model reveals the agent with
`navigateToAgent` when `ComposerInput.isRevealed()` is false, and holds one pending request until an
input that is revealed attaches. `isRevealed` is stricter than `isLive`: a compact overlay panel and
an unfocused pane leave the input live but unable to take the keyboard, so the agent is revealed
first, which also moves pane focus. `Composer` makes `revealed` part of `ComposerInput`, so a change
re-runs `attachInput`, which is what tells the pending request the input is on screen. The draft
half has nothing to navigate to and does not pull the user back to a draft they left, so it rejects
when not revealed. Sheets, menus, pickers and modals the composer opens over itself do not count
against `revealed`: `takeInputFocus` closes them through the `OverlayDismissalScope` in
`components/ui/overlay-dismissal.tsx`, and places the cursor only after the commit that removes
them. A web modal hands focus back to the button that opened it in that commit, so placing the
cursor earlier loses it. The overlay primitives register while open: every controlled bottom
sheet through `useIsolatedBottomSheetVisibility`, `AdaptiveModalSheet`'s desktop dialog, `MenuRoot`,
`Combobox`, and `Modal` through its `onRequestClose`. Their close must close; a surface built on none
of them must call `useDismissibleOverlay`. Overlapping requests share each overlay's close, and the
latest request places the cursor while the earlier ones settle with it. A native input reports
focus synchronously and refuses it while not editable, such as during dictation, so the native
branch checks `isFocused()` instead of trusting the call. The agent panel opens the scope around its whole chat
because the tracks above the composer render outside `Composer`, and the composer's own scope
joins it. `toPluginComposer` clears the Command Center's focus restore, which would
otherwise take the cursor back when the Command Center closes. Native `focus` opens the keyboard, so
the app's web-only `focusInput` stays separate and plugins go through `takeInputFocus`.
`ComposerAgent` stays for the built-in controls. Add a capability to the `ComposerModel`, both
implementations and `toPluginComposer`; never branch on `context` in the host.
+80 -4
View File
@@ -594,7 +594,7 @@ Release notes depend on the changelog heading format. The heading **must** be st
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Content between the release heading and the first `###` is the release's [highlights](#changelog-highlights). The What's new sheet, the website changelog and the GitHub release body show the highlights and keep the sections collapsed behind one control: Show all changes in the app and on the website, and an All changes `<details>` block that `Release Notes Sync` writes into the release body, so the file itself never needs HTML. A release with nothing before its first `###` shows its sections. Older entries that open with summary paragraphs are treated as highlights. The app, the website and the release scripts each parse the file with their own code, and installed apps keep the parser they shipped with, so those two heading rules are fixed: new kinds of content go inside the highlights or a section as Markdown, never behind a new heading level or marker. `scripts/test-support/changelog-structure.json` holds the cases all three parsers are tested against; change a structure rule there first. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
## Changelog policy
@@ -613,7 +613,7 @@ Older prerelease bodies on GitHub keep the notes they were synced with; `Release
- The release agent writes the Chinese entry in the changelog commit, after the English entry is approved. Every beta that rewrites the English entry rewrites the Chinese one too, and promotion renames both. `packages/website/e2e/i18n.spec.ts` fails CI on the release commit when any entry has no current translation.
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice. Delete Chinese entries whose English entry was renamed.
- Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
- Keep the English highlights, sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
## Changelog ownership
@@ -656,7 +656,7 @@ claims a general improvement, rewrite it with the concrete change.
## Changelog conciseness
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
Every section bullet must be scannable at a glance. The changelog is not release documentation — it's a list. [Highlights](#changelog-highlights) get more room; every other bullet follows these limits.
- **One sentence per bullet, max.** If a bullet contains two sentences, the second one is doing work that belongs in product docs, not the changelog. Cut it.
- **No trailing periods.** Bullets are list items, not prose. Drop the period at the end of every bullet, including the period inside any bolded lead-in. `**Configurable terminal scrollback**` not `**Configurable terminal scrollback.**`.
@@ -666,6 +666,57 @@ Every bullet must be scannable at a glance. The changelog is not release documen
- **Stop after identifying the change.** Do not explain LAN/WAN topology, TLS handshakes, IPC, or other architecture in a changelog bullet. Put necessary background in product docs.
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
## Changelog highlights
Every release entry leads with highlights: the changes a user would name first when asked what is
new. They are a list above the first `###` section, ranked as
[Changelog ordering](#changelog-ordering) describes. The sections below are the full record; a
reader who stops after the highlights should have missed nothing they would ask about.
```markdown
## 0.16.0 - 2026-11-20
- **Undo workspace archive:** Archiving a workspace in the app now shows a "Workspace archived" toast for six seconds. Undo, or Cmd/Ctrl+Z, restores the workspace with its tabs and agents. ([#8904](https://github.com/getpaseo/paseo/pull/8904))
### Added
```
- **The lead-in names what is new in four words at most.** Bold it, colon inside the bold. A reader
who sees only the bold words should know what this release added or changed. A category lead-in
such as `**Polish:**` gathers several related changes.
- **The lead-in is not a place or something Paseo already had.** A device, screen or area names
where the change landed. A feature name that was true of the last release hides the news: Paseo
already had keyboard shortcuts, so the lead-in says they now work on mobile.
- **The first sentence says the plain thing.** Write what a user would tell a friend: Paseo now
shows the background tasks an agent is running. Leave out the conditions and mechanics that make
it true.
- **Describe the new behavior, not the old one.** Say what is faster or better now, and do not
dwell on how slow or broken it was before. A number is welcome as a short aside after the new
behavior is stated: seconds, a percentage, a multiple.
- **Do not list what a sentence already covers.** After "the same shortcuts as desktop", naming
five of them adds nothing. Keep a list only when it sets the scope, such as the providers a
feature works with.
- **The description runs one to three sentences.** Sentences end with periods, and the attribution
follows the last one. [Changelog wording](#changelog-wording) applies in full.
- **A highlight replaces the section bullets it covers.** A PR appears once in an entry, so its
attribution moves to the highlight.
- **A highlight is something a user would name.** Everything else stays a section bullet. A patch
release still has one: the change it ships for.
- **Fixes are routine and stay in `### Fixed`.** Do not add a summary of fixes. A fix, or a group
of related fixes, becomes a highlight only when it passes the same test as a feature, and then
it is written as what works now: `**Fewer reconnects on return:**`.
| Avoid | Write |
| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `**iPad and touch screens:**` | `**Touch-sized tablet controls:**` |
| `**Hardware keyboard shortcuts:**` | `**Keyboard shortcuts on mobile:**` |
| When an agent ends its turn while shells it started are still running, a pill lists them | Paseo now shows the background tasks an agent is running |
| A diff no longer waits 4 to 40 seconds behind worktree scans | Opening Changes shows the file tree right away, and diffs load about 6 times sooner on hosts with many worktrees |
| The same shortcuts as desktop: command center, file picker, new workspace, workspace jumps | The same shortcuts as desktop, including your custom bindings |
Before showing a draft, read only the bold lead-ins top to bottom. Each one must answer "what is
new?" without its description, and each must outrank the one below it.
## Changelog attribution
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
@@ -697,7 +748,32 @@ Rules:
## Changelog ordering
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
Rank highlights by how much of a user's day the change touches: how many users get it, and how
often they meet it. Put every highlight in one of four groups, then order each group by the same
question.
1. **New capabilities.** Something a user could not see or do in the last release:
`Background tasks`, `Plugin sidebar views`.
2. **Everyday things that got better.** Speed, steadiness and polish in what users already do
every session: `Faster diff loading`, `Polish`.
3. **Changes for part of the audience.** They need a particular device, provider or setup:
`Keyboard shortcuts on mobile`, `Touch-sized tablet controls`.
4. **Small conveniences and highlighted fixes.** A user is glad to find them and would not update
for them: `Undo workspace archive`, `Fewer reconnects on return`. Fixes are routine, so one
that earned a highlight still follows everything new or improved.
- **Reach decides before kind.** A new capability that needs a hardware keyboard is in group 3.
- **Rank the whole list in one pass.** The order you collected the pull requests in, the alphabet
and the order of the sections below are not rankings.
- **Show the groups with the draft.** List each highlight under its group when you ask for
approval, so the maintainer corrects the test and not one position.
- **A maintainer's note that a change is big moves that change up.** It does not rank the changes
it names against each other, and it does not lower the ones it leaves out.
- **A correction to one highlight is a correction to the test.** Work out which group or question
you got wrong, rank every highlight again, and then edit the file. Moving only the named item
leaves the same mistake in the others.
Bullets within each section (Added, Changed, Fixed) follow this order:
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
2. **Quality-of-life improvements** — polish, performance, smoother interactions.
+1 -1
View File
@@ -1 +1 @@
sha256-21qteVXVoL7S/wF0aGxHNEkTX8in6UF6+wWEZi8N3HA=
sha256-vG7v6uq2ADtOILEuRq9q3wTDUENAhRBbzI7UGBsWOqo=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39637,13 +39637,13 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/server": "0.15.4",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/server": "0.16.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39890,11 +39890,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.15.4",
"@getpaseo/relay": "0.15.4",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40149,7 +40149,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
@@ -40233,8 +40233,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40244,8 +40244,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40267,7 +40267,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.4",
"@getpaseo/highlight": "0.15.4",
"@getpaseo/plugin": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/relay": "0.15.4",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/highlight": "0.16.0-beta.1",
"@getpaseo/plugin": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41474,7 +41474,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43084,7 +43084,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -22,7 +22,7 @@ import {
restoreUnchangedViewedTimeline,
restoreViewedTimelineHoldingCatchUp,
restoreViewedTimelineWithHeldResponse,
returnWithFreshnessCheckUnanswered,
reopenWithFreshnessCheckUnanswered,
waitForResumeAnswer,
reopenAppWatchingForUpdatingToast,
watchForUpdatingToast,
@@ -103,7 +103,9 @@ test.describe("Agent timeline resume", () => {
}
});
test("a check the daemon has not answered reports the update until it does", async ({ page }) => {
test("opening cached history reports an unanswered check until the daemon answers", async ({
page,
}) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 3 });
@@ -112,7 +114,7 @@ test.describe("Agent timeline resume", () => {
await expectTimelinePromptVisible(page, agent.newestPrompt);
const requests = rememberTimelineRequestCounts(gate);
await returnWithFreshnessCheckUnanswered(page, gate);
await reopenWithFreshnessCheckUnanswered(page, gate, agent);
await answerFreshnessCheck(page, gate);
expectResumeAnsweredWithoutFetch(gate, requests);
@@ -125,11 +125,13 @@ async function renameWorkspaceViaSidebar(
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
const modalPrefix = `sidebar-workspace-rename-modal-${serverId}:${input.workspaceId}`;
const renameInput = page.getByTestId(`${modalPrefix}-input`);
await expect(renameInput).toBeVisible({ timeout: 10_000 });
// Desktop-width sidebars rename in place.
const renameInput = page.getByTestId(
`sidebar-workspace-rename-input-${serverId}:${input.workspaceId}`,
);
await expect(renameInput).toBeFocused({ timeout: 10_000 });
await renameInput.fill(input.title);
await page.getByTestId(`${modalPrefix}-submit`).click();
await renameInput.press("Enter");
await expect(renameInput).toHaveCount(0, { timeout: 15_000 });
}
@@ -19,6 +19,7 @@ import {
openSheetAsTab,
expectDeclinedDiscardPrompt,
expectSheetAtSnap,
promptMentioning,
replaceSheetFileText,
selectTabFromSwitcher,
sheet,
@@ -28,17 +29,6 @@ import {
const FILE_LINK = "linked.ts:3";
function promptMentioning(target: string): string {
return [
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].join("\n");
}
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
page,
}) => {
@@ -13,6 +13,7 @@ import {
ensureExplorerSidebar,
openFilesPanel,
waitForWorkspaceTabsVisible,
dragTabIntoExplorer,
} from "../support/helpers/workspace-tabs";
function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
@@ -194,26 +195,6 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
}
});
async function dragTabIntoExplorer(
page: Parameters<typeof ensureExplorerSidebar>[0],
tabId: string,
) {
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
const sourceBox = await source.boundingBox();
const destinationBox = await destination.boundingBox();
if (!sourceBox || !destinationBox)
throw new Error("Tab drag requires visible source and destination");
const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 };
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x + 12, start.y + 4);
await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, {
steps: 20,
});
await page.mouse.up();
}
test("moves agents into Explorer and keeps the shared Agent launcher available", async ({
page,
}, testInfo) => {
@@ -2,6 +2,10 @@ import { test } from "../support/fixtures";
import {
CHECKLIST,
withComposerToolsExample,
withPluginFocusingAComposer,
withPluginFocusingASubagent,
withPluginFocusingAnAgentWithContextUsage,
withPluginHoldingNewWorkspaceComposer,
withPluginWritingToAnotherWorkspace,
} from "../support/helpers/plugin-composers-by-reference";
@@ -39,6 +43,17 @@ test("the panel follows which agent is focused", async ({ page }) => {
});
});
test("an Action in the compact controls sheet closes the sheet and focuses the composer", async ({
page,
}) => {
test.setTimeout(120_000);
await withComposerToolsExample(page, async (author) => {
await author.useCompactLayout();
await author.insertChecklistFromControlsSheet();
await author.expectComposerReadyToType(CHECKLIST);
});
});
test("a plugin writes to an agent's composer while its workspace is not on screen", async ({
page,
}) => {
@@ -51,3 +66,158 @@ test("a plugin writes to an agent's composer while its workspace is not on scree
await author.expectComposerText("A note from a plugin");
});
});
test("a pill that inserts text and focuses leaves the cursor in the composer, ready to type", async ({
page,
}) => {
test.setTimeout(120_000);
await withPluginFocusingAComposer(page, async (author) => {
await author.openTargetAgent();
await author.moveFocusOutOfComposer();
await author.pressPill();
await author.expectComposerFocused();
await author.typeAtCursor(" world");
await author.expectComposerText("HELLO world");
});
});
test("focus reaches an agent in another tab of the workspace", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingAComposer(page, async (author) => {
await author.openTargetAgent();
await author.openOtherAgent();
await author.focusFromCommandCenter();
await author.expectOnTargetAgent();
await author.expectComposerFocused();
});
});
test("focus reaches an agent in another workspace, with the selection asked for", async ({
page,
}) => {
test.setTimeout(120_000);
await withPluginFocusingAComposer(page, async (author) => {
await author.openTargetAgent();
await author.fillComposer("typed before");
await author.goToAnotherWorkspace();
await author.selectPromptFromCommandCenter();
await author.expectOnTargetAgent();
await author.expectComposerFocused();
await author.typeAtCursor("replaced");
await author.expectComposerText("replaced");
});
});
test("focus closes the compact Explorer panel covering the composer", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingAComposer(page, async (author) => {
await author.useCompactLayout();
await author.openTargetAgent();
await author.coverComposerWithExplorerPanel();
await author.pressPanelFocusButton();
await author.expectExplorerPanelClosed();
await author.expectComposerFocused();
});
});
test("focus rejects on a New workspace composer the user has left", async ({ page }) => {
test.setTimeout(120_000);
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
await author.openNewWorkspace();
await author.expectHeldComposerFocusResult("focused");
await author.openSettings();
await author.expectHeldComposerFocusResult("The composer is not on screen.");
});
});
test("focus closes a picker the composer opened over itself", async ({ page }) => {
test.setTimeout(120_000);
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
await author.openNewWorkspace();
await author.openThinkingPicker();
await author.expectHeldComposerFocusResult("focused");
await author.expectHeldComposerReadyToType();
});
});
test("focus closes an image preview the composer opened over itself", async ({ page }) => {
test.setTimeout(120_000);
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
await author.openNewWorkspace();
await author.previewAttachedImage();
await author.expectHeldComposerFocusResult("focused");
await author.expectHeldComposerReadyToType();
});
});
test("overlapping focus calls end at the later selection while a preview closes", async ({
page,
}) => {
test.setTimeout(120_000);
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
await author.openNewWorkspace();
await author.typeInHeldComposer("HELLO");
await author.previewAttachedImage();
await author.expectOverlappingFocusesKeepTheLatestSelection();
});
});
test("focus closes the context usage sheet on a phone", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingAnAgentWithContextUsage(page, async (author) => {
await author.openContextUsageOnPhone();
await author.focusAgent();
await author.expectReadyToType();
});
});
test("focus closes a page the composer's Subagents track opened over it", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingASubagent(page, async (author) => {
await author.openParentAgent();
await author.openSubagentPageInParentComposer();
await author.focusParent();
await author.expectParentReadyToType();
});
});
test("focus reaches a New workspace composer opened from a compact Explorer", async ({ page }) => {
test.setTimeout(120_000);
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
await author.leaveCompactExplorerForNewWorkspace();
await author.expectHeldComposerFocusResult("focused");
});
});
test("focus opens a compact subagent as a tab with its composer", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingASubagent(page, async (author) => {
await author.openParentAgent();
await author.openExplorer();
await author.focusSubagent();
await author.expectSubagentTabReadyToType();
});
});
test("focus closes a file sheet still opening over an Explorer composer", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingASubagent(page, async (author) => {
await author.keepParentInExplorerOnCompactScreen();
await author.focusParent();
await author.expectParentReadyToType();
await author.focusParentAsAFileSheetOpens();
await author.expectParentReadyToType();
});
});
test("focus reaches an agent kept in the Explorer on a compact screen", async ({ page }) => {
test.setTimeout(120_000);
await withPluginFocusingASubagent(page, async (author) => {
await author.keepParentInExplorerOnCompactScreen();
await author.focusParent();
await author.expectParentReadyToType();
await author.coverExplorerComposerWithFileSheet();
await author.focusParent();
await author.expectParentReadyToType();
});
});
+43 -14
View File
@@ -1,7 +1,14 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { openSettingsSection } from "../support/helpers/settings";
import { openWhatsNew, release, serveChangelog } from "../support/helpers/changelog";
import {
allChangesToggle,
hideAllChanges,
openWhatsNew,
release,
serveChangelog,
showAllChanges,
} from "../support/helpers/changelog";
const DISCORD_DESTINATION =
/^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/;
@@ -73,26 +80,30 @@ test("opens troubleshooting and support destinations", async ({ page }) => {
});
test("renders the changelog in the app and links the website", async ({ page }) => {
// A callout, a section name the app has never seen, and a fenced sample whose
// contents look like a release heading.
// Highlights, a section name the app has never seen, a callout, and a fenced
// sample whose contents look like a release heading.
await serveChangelog(page, [
"# Changelog",
"",
"## 9.1.0 - 2026-03-04",
"",
"Headline release note.",
"",
"> [!WARNING]",
"> Read this before upgrading.",
"- **Background tasks:** Paseo now shows the tasks an agent is running.",
"",
"### Sparkles",
"",
"- Added a brand new thing",
"",
"> [!WARNING]",
"> Read this before upgrading.",
"",
"```md",
"## 0.0.0 - 1999-01-01",
"```",
"",
"### Fixed",
"",
"- Fixed a crash",
"",
"## 9.0.0 - 2026-02-01",
"",
"### Fixed",
@@ -104,14 +115,32 @@ test("renders the changelog in the app and links the website", async ({ page })
const sheet = await openWhatsNew(page);
const latest = release(sheet, "9.1.0");
const older = release(sheet, "9.0.0");
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
await expect(latest.getByText("Headline release note.")).toBeVisible();
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
await expect(release(sheet, "9.0.0")).toBeVisible();
await expect(release(sheet, "0.0.0")).toHaveCount(0);
await test.step("leads a release with its highlights and tucks the rest away", async () => {
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
await expect(latest.getByText("Paseo now shows the tasks an agent is running.")).toBeVisible();
await expect(latest.getByText("Added a brand new thing")).toHaveCount(0);
await expect(latest.getByText("Fixed a crash")).toHaveCount(0);
});
await test.step("shows every section on request and tucks them away again", async () => {
await showAllChanges(latest);
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
await expect(latest.getByText("Fixed a crash")).toBeVisible();
await expect(release(sheet, "0.0.0")).toHaveCount(0);
await hideAllChanges(latest);
await expect(latest.getByText("Added a brand new thing")).toHaveCount(0);
await expect(latest.getByText("Paseo now shows the tasks an agent is running.")).toBeVisible();
});
await test.step("shows a release without highlights whole", async () => {
await expect(older.getByText("Fixed an older thing")).toBeVisible();
await expect(allChangesToggle(older)).toHaveCount(0);
});
await expectExternalPage(page, "changelog-open-website", CHANGELOG_DESTINATION);
await closeSheet(page, "changelog-sheet");
@@ -3,6 +3,7 @@ import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
@@ -12,9 +13,13 @@ function workspaceRenameModalTestId(workspaceId: string, suffix: string): string
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
}
async function openRenameModal(page: Page, workspaceId: string) {
function workspaceRenameInputTestId(workspaceId: string): string {
return `sidebar-workspace-rename-input-${getServerId()}:${workspaceId}`;
}
async function chooseRenameFromKebab(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
const row = page.getByTestId(workspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
@@ -25,40 +30,87 @@ async function openRenameModal(page: Page, workspaceId: string) {
const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`);
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
}
const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input"));
await expect(input).toBeVisible({ timeout: 10_000 });
return input;
/** Lets the app render a server message the gate just forwarded. */
async function waitForTwoFrames(page: Page): Promise<void> {
await page.evaluate(async () => {
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
await nextFrame();
await nextFrame();
});
}
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
/** Sets the title from a second daemon client and waits until this page has rendered it. */
async function renameFromAnotherClient(
page: Page,
gate: DaemonWebSocketGate,
workspace: Awaited<ReturnType<typeof seedWorkspace>>,
title: string,
): Promise<void> {
gate.holdNextServerMessage("workspace_update");
await workspace.client.setWorkspaceTitle(workspace.workspaceId, title);
await gate.waitForHeldServerMessage("workspace_update");
gate.releaseHeldServerMessage("workspace_update");
await waitForTwoFrames(page);
}
interface Box {
x: number;
y: number;
width: number;
height: number;
}
async function rowBox(page: Page, workspaceId: string): Promise<Box> {
const box = await page.getByTestId(workspaceRowTestId(workspaceId)).boundingBox();
if (!box) throw new Error(`sidebar row ${workspaceId} has no layout box`);
return box;
}
// In Model B the workspace title is its identity: renaming sets a custom title
// layered over the derived branch/directory name, and reconciliation never
// touches it. The sidebar row shows the title verbatim — no branch mutation.
test.describe("Sidebar workspace rename", () => {
test("renaming via kebab sets a custom title that survives reload", async ({ page }) => {
test("renaming in place sets a custom title without moving the row, and survives reload", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" });
try {
expect(workspace.workspaceName).toBe("main");
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
timeout: 30_000,
});
const restingBox = await rowBox(page, workspace.workspaceId);
const input = await openRenameModal(page, workspace.workspaceId);
await chooseRenameFromKebab(page, workspace.workspaceId);
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await expect(input).toBeFocused();
await expect(input).toBeEditable();
await expect(input).toHaveValue("main");
await expect(
page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "input")),
).toHaveCount(0);
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
const customTitle = "Payments Refactor";
await input.fill(customTitle);
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
// The whole title is selected, so typing replaces it.
await page.keyboard.type(customTitle);
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
await page.keyboard.press("Enter");
await expect(input).toHaveCount(0, { timeout: 15_000 });
// The title is shown exactly as typed — not slugified into a branch name.
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
customTitle,
{ timeout: 15_000 },
);
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
// The custom title is backing metadata on the workspace: a full reload
// re-resolves the descriptor from persistence and must not lose it. This
@@ -73,6 +125,259 @@ test.describe("Sidebar workspace rename", () => {
await workspace.cleanup();
}
});
test("Escape discards an in-place rename and clicking away saves it", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-blur-" });
try {
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await page.keyboard.type("Discarded title");
await page.keyboard.press("Escape");
await expect(input).toHaveCount(0);
await expect(row).toContainText("main");
await expect(row).not.toContainText("Discarded title");
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
// Clicking inside the field places the caret; it does not press the row underneath.
await input.click();
await expect(input).toBeFocused();
await input.fill("Saved on blur");
// Click empty space in the main pane, away from any control.
await page.mouse.click(900, 700);
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(row).toContainText("Saved on blur");
} finally {
await workspace.cleanup();
}
});
test("the submitted title stays on screen until the renamed workspace arrives", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-hold-" });
try {
const gate = await installDaemonWebSocketGate(page);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await page.keyboard.type("Held title");
// The daemon answers the rename before it pushes the renamed workspace.
gate.holdNextServerMessage("workspace_update");
await page.keyboard.press("Enter");
await gate.waitForHeldServerMessage("workspace_update");
await expect(input).toHaveValue("Held title");
await expect(input).not.toBeEditable();
await expect(row).not.toContainText("main");
gate.releaseHeldServerMessage("workspace_update");
await expect(input).toHaveCount(0);
await expect(row).toContainText("Held title");
} finally {
await workspace.cleanup();
}
});
test("an untouched in-place rename keeps a title set elsewhere while it was open", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-" });
try {
const gate = await installDaemonWebSocketGate(page);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await expect(input).toHaveValue("main");
await renameFromAnotherClient(page, gate, workspace, "External title");
await page.mouse.click(900, 700);
await expect(input).toHaveCount(0);
await expect(row).toContainText("External title");
expect(gate.getClientRequestCount("workspace.title.set.request")).toBe(0);
} finally {
await workspace.cleanup();
}
});
test("saving after a rename elsewhere holds the submitted title until it arrives", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-save-" });
try {
const gate = await installDaemonWebSocketGate(page);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await renameFromAnotherClient(page, gate, workspace, "External title");
await input.fill("Submitted title");
gate.holdNextServerMessage("workspace_update");
await page.keyboard.press("Enter");
await gate.waitForHeldServerMessage("workspace_update");
await expect(input).toHaveValue("Submitted title");
await expect(row).not.toContainText("External title");
gate.releaseHeldServerMessage("workspace_update");
await expect(input).toHaveCount(0);
await expect(row).toContainText("Submitted title");
} finally {
await workspace.cleanup();
}
});
test("submitting the title another client just set closes the field", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-same-" });
try {
const gate = await installDaemonWebSocketGate(page);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await renameFromAnotherClient(page, gate, workspace, "External title");
// The daemon accepts it, but the title does not change, so no renamed workspace follows.
await input.fill("External title");
await page.keyboard.press("Enter");
await expect(input).toHaveCount(0);
await expect(row).toContainText("External title");
// The row is a row again: its menu opens and renames.
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
} finally {
await workspace.cleanup();
}
});
test("a rename elsewhere delivered while saving does not close the field", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-pending-" });
try {
const gate = await installDaemonWebSocketGate(page);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await input.fill("Submitted title");
// Another client's rename lands just before ours. The page receives, in daemon order: that
// rename, our response, our renamed workspace. Each is held until the previous is rendered.
gate.holdNextServerMessage("workspace_update");
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "External title");
await gate.waitForHeldServerMessage("workspace_update");
gate.holdNextServerMessage("workspace.title.set.response");
gate.holdNextServerMessage("workspace_update");
await page.keyboard.press("Enter");
await gate.waitForHeldServerMessage("workspace.title.set.response");
// Releases the oldest held update: the other client's. Ours stays held behind it.
gate.releaseHeldServerMessage("workspace_update");
await gate.waitForHeldServerMessage("workspace_update");
await waitForTwoFrames(page);
await expect(input).toHaveValue("Submitted title");
gate.releaseHeldServerMessage("workspace.title.set.response");
await waitForTwoFrames(page);
await expect(input).toHaveValue("Submitted title");
await expect(row).not.toContainText("External title");
gate.releaseHeldServerMessage("workspace_update");
await expect(input).toHaveCount(0);
await expect(row).toContainText("Submitted title");
} finally {
await workspace.cleanup();
}
});
test("a later rename that replaces the saved title closes the field", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-superseded-" });
try {
const gate = await installDaemonWebSocketGate(page);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
await input.fill("Submitted title");
// The daemon builds each update from its latest record, so a rename that lands after ours
// is accepted can be published in its place. Our update stays held: the page never sees it.
gate.holdNextServerMessage("workspace.title.set.response");
gate.holdNextServerMessage("workspace_update");
await page.keyboard.press("Enter");
await gate.waitForHeldServerMessage("workspace.title.set.response");
await gate.waitForHeldServerMessage("workspace_update");
gate.releaseHeldServerMessage("workspace.title.set.response");
await waitForTwoFrames(page);
await expect(input).toHaveValue("Submitted title");
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "Later title");
await expect(input).toHaveCount(0);
await expect(row).toContainText("Later title");
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
} finally {
await workspace.cleanup();
}
});
test("below the lg breakpoint renaming opens the modal", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-modal-" });
try {
await page.setViewportSize({ width: 800, height: 800 });
await gotoAppShell(page);
await chooseRenameFromKebab(page, workspace.workspaceId);
const input = page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "input"));
await expect(input).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId))).toHaveCount(
0,
);
await input.fill("Modal Title");
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
"Modal Title",
);
} finally {
await workspace.cleanup();
}
});
});
async function toggleBackgroundWorkspaces(page: Page): Promise<void> {
@@ -0,0 +1,142 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// A long user message paints capped, with its fade and Show more, in the frame it mounts in. These
// tests sample every painted frame from before the chat opens and require each long message to
// show one state, the settled one, for its whole life on screen.
interface PreviewFrame {
height: number;
fade: boolean;
control: string | null;
}
declare global {
interface Window {
__previewFrames?: Record<string, PreviewFrame[]>;
}
}
function longPrompt(name: string) {
return Array.from({ length: 40 }, (_, line) => `${name} line ${line + 1}`).join("\n");
}
async function recordPreviewFrames(page: Page) {
await page.addInitScript(() => {
const frames: Record<string, PreviewFrame[]> = {};
window.__previewFrames = frames;
const sample = () => {
for (const row of document.querySelectorAll('[data-testid="user-message"]')) {
const rect = row.getBoundingClientRect();
if (rect.height === 0) continue;
const text = row.querySelector('[data-message-text="true"]');
if (!text) continue;
const name = (text.textContent ?? "").split(" line ")[0];
// The preview is the ancestor that carries the height cap; the fade paints inside it.
let preview = text.parentElement;
while (preview && preview !== row && getComputedStyle(preview).maxHeight === "none") {
preview = preview.parentElement;
}
const control = [...row.querySelectorAll("*")].find(
(node) => node.children.length === 0 && /^Show (more|less)$/.test(node.textContent ?? ""),
);
const frame = {
height: Math.round(rect.height),
fade: preview !== null && preview !== row && preview.querySelector("svg") !== null,
control: control?.textContent ?? null,
};
const seen = (frames[name] ??= []);
if (JSON.stringify(seen.at(-1)) !== JSON.stringify(frame)) seen.push(frame);
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
}
async function expectPaintedCollapsedFromFirstFrame(page: Page, name: string) {
const message = page.getByTestId("user-message").filter({ hasText: `${name} line 1` });
await expect(message.getByRole("button", { name: "Show more" })).toBeVisible();
// Let any late measurement land before reading the frames.
await page.waitForTimeout(500);
const frames = await page.evaluate((key) => window.__previewFrames?.[key] ?? [], name);
expect(frames).toEqual([{ height: expect.any(Number), fade: true, control: "Show more" }]);
}
test.describe("long user message preview", () => {
test("opening a chat paints its long message collapsed from the first frame", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "user-message-open-",
title: "Preview open",
initialPrompt: longPrompt("Opened prompt"),
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await recordPreviewFrames(page);
await openAgentRoute(page, agent);
await expectPaintedCollapsedFromFirstFrame(page, "Opened prompt");
await page.reload();
await expectPaintedCollapsedFromFirstFrame(page, "Opened prompt");
} finally {
await agent.cleanup();
}
});
test("a long message arriving in an open chat paints collapsed from the first frame", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "user-message-arrive-",
title: "Preview arrive",
initialPrompt: "short",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await recordPreviewFrames(page);
await openAgentRoute(page, agent);
await expectAgentIdle(page);
await agent.client.sendAgentMessage(agent.agentId, longPrompt("Arrived prompt"));
await expectPaintedCollapsedFromFirstFrame(page, "Arrived prompt");
} finally {
await agent.cleanup();
}
});
test("a long message that arrived while its tab was hidden paints collapsed on return", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "user-message-hidden-",
title: "Preview first",
initialPrompt: "short",
});
try {
await agent.client.createAgent({
provider: "mock",
cwd: agent.cwd,
workspaceId: agent.workspaceId,
title: "Preview second",
modeId: "load-test",
model: "e2e-fast-stream",
});
await agent.client.waitForFinish(agent.agentId, 30_000);
await recordPreviewFrames(page);
await openAgentRoute(page, agent);
await expectAgentIdle(page);
await page.getByRole("button", { name: "Preview second", exact: true }).click();
await agent.client.sendAgentMessage(agent.agentId, longPrompt("Hidden prompt"));
await agent.client.waitForFinish(agent.agentId, 30_000);
await page.getByRole("button", { name: "Preview first", exact: true }).click();
await expectPaintedCollapsedFromFirstFrame(page, "Hidden prompt");
} finally {
await agent.cleanup();
}
});
});
@@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
restoreArchivedWorkspace,
@@ -300,9 +299,7 @@ test.describe("Worktree restore", () => {
).toHaveText(switchedBranch, { timeout: 30_000 });
});
test("restores the workspace before explicitly unarchiving each selected agent", async ({
page,
}) => {
test("restores the workspace and all agents closed by its archive", async ({ page }) => {
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
agentCount: 2,
keepAgentsArchived: true,
@@ -330,13 +327,11 @@ test.describe("Worktree restore", () => {
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toHaveAttribute("aria-selected", "true");
expect(await fetchAgentArchivedAt(client, firstAgent.id)).not.toBeNull();
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
.toBeNull();
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
await expect.poll(() => fetchAgentArchivedAt(client, firstAgent.id)).toBeNull();
await expect.poll(() => fetchAgentArchivedAt(client, secondAgent.id)).toBeNull();
await expect(
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
).toBeVisible();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 30_000,
});
@@ -346,13 +341,8 @@ test.describe("Worktree restore", () => {
await expect(
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Unarchive" }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
.toBeNull();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
});
test("restores a branchless workspace so its archived agent can be used again", async ({
@@ -410,12 +400,7 @@ test.describe("Worktree restore", () => {
await expect
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({
timeout: 30_000,
});
await captureBranchlessRestoreStep(page, "02-unarchive-agent.png");
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toHaveCount(0);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
@@ -506,7 +491,11 @@ test.describe("Worktree restore", () => {
});
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
const screenshotDirectory = path.join(
process.cwd(),
".artifacts",
"paseo-branchless-restore-evidence",
);
mkdirSync(screenshotDirectory, { recursive: true });
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
}
@@ -27,3 +27,18 @@ export async function openWhatsNew(page: Page): Promise<Locator> {
export function release(sheet: Locator, version: string): Locator {
return sheet.getByTestId(`changelog-release-${version}`);
}
/** The one control a release with highlights has for its sections, in either state. */
export function allChangesToggle(entry: Locator): Locator {
return entry.getByRole("button", { name: /^(Show|Hide) all changes$/ });
}
export async function showAllChanges(entry: Locator): Promise<void> {
await entry.getByRole("button", { name: "Show all changes" }).click();
await expect(entry.getByRole("button", { name: "Hide all changes" })).toBeVisible();
}
export async function hideAllChanges(entry: Locator): Promise<void> {
await entry.getByRole("button", { name: "Hide all changes" }).click();
await expect(entry.getByRole("button", { name: "Show all changes" })).toBeVisible();
}
@@ -3,16 +3,37 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { attachComposerTestImage, openImageLightbox } from "./composer";
import {
contextWindowDetails,
pressContextWindowMeter,
seedAgentWithContextWindow,
} from "./context-window";
import { gotoWorkspace } from "./launcher";
import { openCommandCenter } from "./command-center";
import { openAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent";
import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
import {
expectNoSheetOpen,
expectOpenTabCount,
promptMentioning,
sheet,
tapSheetBackdrop,
} from "./sheet";
import { openSubagentFromCompactTrack, seedSubagent } from "./subagents";
import { getServerId } from "./server-id";
import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs";
import {
createAgentTabFromMenu,
dragTabIntoExplorer,
ensureExplorerSidebar,
} from "./workspace-tabs";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n";
const FILE_LINK = "linked.ts:3";
type Workspace = Awaited<ReturnType<typeof seedWorkspace>>;
@@ -50,6 +71,9 @@ export async function withComposerToolsExample(
focusTheAgentTab(): Promise<void>;
expectPanelFocusedAgent(focused: boolean): Promise<void>;
expectPanelShowsReview(needsReview: boolean): Promise<void>;
useCompactLayout(): Promise<void>;
insertChecklistFromControlsSheet(): Promise<void>;
expectComposerReadyToType(text: string): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-tools-", title: "Composer tools" });
@@ -115,6 +139,23 @@ export async function withComposerToolsExample(
expect(
page.getByText(`Needs review: ${needsReview ? "yes" : "no"}`, { exact: true }),
).toBeVisible()),
useCompactLayout: () =>
test.step("narrow the window to a phone", async () => {
await page.setViewportSize(COMPACT);
await expect(prompt(page).filter({ visible: true })).toBeVisible();
}),
insertChecklistFromControlsSheet: () =>
test.step("press the Action in the composer's controls sheet", async () => {
await page.getByRole("button", { name: /^Select model/ }).click();
await page.getByText("Insert checklist", { exact: true }).click();
}),
expectComposerReadyToType: (text) =>
test.step("the sheet is closed and the cursor is in the composer", async () => {
await expectNoSheetOpen(page);
const input = prompt(page).filter({ visible: true });
await expect(input).toHaveValue(text);
await expect(input).toBeFocused();
}),
});
} finally {
await client.removePlugin("composer-controls-example");
@@ -205,4 +246,516 @@ export async function withPluginWritingToAnotherWorkspace(
}
}
/**
* A plugin that reaches one agent's composer from a pill and from Command Center items, and asks
* for focus, wherever the user is.
*/
export async function withPluginFocusingAComposer(
page: Page,
run: (author: {
openOtherAgent(): Promise<void>;
openTargetAgent(): Promise<void>;
goToAnotherWorkspace(): Promise<void>;
moveFocusOutOfComposer(): Promise<void>;
pressPill(): Promise<void>;
focusFromCommandCenter(): Promise<void>;
selectPromptFromCommandCenter(): Promise<void>;
useCompactLayout(): Promise<void>;
coverComposerWithExplorerPanel(): Promise<void>;
pressPanelFocusButton(): Promise<void>;
expectExplorerPanelClosed(): Promise<void>;
fillComposer(text: string): Promise<void>;
expectOnTargetAgent(): Promise<void>;
expectComposerFocused(): Promise<void>;
typeAtCursor(text: string): Promise<void>;
expectComposerText(text: string): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-focus-", title: "Composer focus" });
const elsewhere = await seedWorkspace({ repoPrefix: "focus-away-", title: "Focus away" });
const target = await createMockAgent(workspace, "Target agent");
const other = await createMockAgent(workspace, "Other agent");
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-focus-"));
const targetId = JSON.stringify(target.id);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "composer-focus", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`import { Pressable, Text } from "react-native";
export default function contribute(client) {
client.addComposerPill({
id: "hello",
workspaceId: ${JSON.stringify(workspace.workspaceId)},
agentId: ${targetId},
button: {
title: "Add HELLO to the composer",
icon: "Hand",
label: "Hello",
behavior: {
kind: "action",
async onPress() {
for (const composer of client.composers({ agentId: ${targetId} })) {
composer.insertText("HELLO");
await composer.focus();
}
},
},
},
});
client.addWorkspacePanel({
id: "tools",
title: "Focus tools",
icon: "ListChecks",
locations: ["explorer"],
Component() {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel="Focus the target agent"
onPress={() => client.composers({ agentId: ${targetId} })[0]?.focus()}
>
<Text>Focus the target agent</Text>
</Pressable>
);
},
});
client.addCommandCenterItem({
id: "focus",
title: "Composer focus: focus the target",
icon: "Flag",
context: "global",
onSelect({ composers }) {
return composers({ agentId: ${targetId} })[0].focus();
},
});
client.addCommandCenterItem({
id: "select",
title: "Composer focus: select the prompt",
icon: "Flag",
context: "global",
onSelect({ composers }) {
const composer = composers({ agentId: ${targetId} })[0];
return composer.focus({ selection: { start: 0, end: composer.prompt.length } });
},
});
return () => {};
}`,
);
const targetTab = () => page.getByTestId(`workspace-tab-agent_${target.id}`);
const workspaceRow = (id: string) =>
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${id}`);
const panelFocusButton = () =>
page.getByRole("button", { name: "Focus the target agent", exact: true });
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await run({
openOtherAgent: () =>
test.step("open another agent, so the target's tab is not showing", async () => {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: other.id });
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
}),
openTargetAgent: () =>
test.step("open the target agent", async () => {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: target.id });
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
}),
goToAnotherWorkspace: () =>
test.step("go to another workspace", async () => {
await workspaceRow(elsewhere.workspaceId).click();
await expect(targetTab().first()).toBeHidden();
}),
moveFocusOutOfComposer: () =>
test.step("the composer does not have focus", async () => {
await prompt(page).blur();
await expect(prompt(page)).not.toBeFocused();
}),
pressPill: () =>
test.step("press the pill", () => page.getByRole("button", { name: "Hello" }).click()),
focusFromCommandCenter: () =>
test.step("run the plugin's focus command", () =>
runCommand(page, "Composer focus: focus the target")),
selectPromptFromCommandCenter: () =>
test.step("run the plugin's select command", () =>
runCommand(page, "Composer focus: select the prompt")),
useCompactLayout: () =>
test.step("use a phone-sized window", () => page.setViewportSize(COMPACT)),
coverComposerWithExplorerPanel: () =>
test.step("open the plugin's panel in the Explorer, covering the composer", async () => {
const explorer = await ensureExplorerSidebar(page);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
await page
.getByTestId("workspace-new-tab-menu")
.filter({ visible: true })
.getByRole("menuitem", { name: "Focus tools", exact: true })
.click();
await expect(panelFocusButton()).toBeVisible();
}),
pressPanelFocusButton: () =>
test.step("press the panel's button", () => panelFocusButton().click()),
expectExplorerPanelClosed: () =>
test.step("the Explorer no longer covers the composer", () =>
expect(panelFocusButton()).toBeHidden({ timeout: 30_000 })),
fillComposer: (text) =>
test.step(`write "${text}" in the composer`, () => prompt(page).fill(text)),
expectOnTargetAgent: () =>
test.step("the target agent's tab is showing", async () => {
await expect(targetTab().filter({ visible: true }).first()).toHaveAttribute(
"aria-selected",
"true",
{ timeout: 30_000 },
);
}),
expectComposerFocused: () =>
test.step("the composer input has focus", () =>
expect(prompt(page)).toBeFocused({ timeout: 30_000 })),
typeAtCursor: (text) => test.step(`type "${text}"`, () => page.keyboard.type(text)),
expectComposerText: (text) =>
test.step("the composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
});
} finally {
await client.removePlugin("composer-focus");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await elsewhere.cleanup();
await workspace.cleanup();
}
}
export { CHECKLIST };
type HeldComposerGlobal = typeof globalThis & {
heldComposer?: {
focus(options?: { selection?: { start: number; end: number } }): Promise<void>;
};
};
export async function withPluginHoldingNewWorkspaceComposer(
page: Page,
run: (author: {
openNewWorkspace(): Promise<void>;
leaveCompactExplorerForNewWorkspace(): Promise<void>;
openSettings(): Promise<void>;
openThinkingPicker(): Promise<void>;
previewAttachedImage(): Promise<void>;
typeInHeldComposer(text: string): Promise<void>;
expectOverlappingFocusesKeepTheLatestSelection(): Promise<void>;
expectHeldComposerFocusResult(result: string): Promise<void>;
expectHeldComposerReadyToType(): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "held-composer-", title: "Held composer" });
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-held-composer-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "held-composer", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`import { useComposer } from "@getpaseo/plugin/client";
function Holder() {
globalThis.heldComposer = useComposer();
return null;
}
export default function contribute(client) {
client.addComposerItem({ id: "holder", Component: Holder });
return () => {};
}`,
);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await run({
openNewWorkspace: () =>
test.step("open the New workspace composer", async () => {
await openGlobalNewWorkspaceComposer(page);
await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
}),
leaveCompactExplorerForNewWorkspace: () =>
test.step("leave a compact Explorer for New workspace with the shortcut", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await page.setViewportSize(COMPACT);
await expect(await ensureExplorerSidebar(page)).toBeVisible();
await page.evaluate(() => {
(globalThis as HeldComposerGlobal).heldComposer = undefined;
});
await page.keyboard.press("ControlOrMeta+n");
await expect(page).toHaveURL(/\/new(?:\?.*)?$/);
// A compact composer mounts plugin items inside its controls sheet.
await page.getByRole("button", { name: /^Select model/ }).click();
await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
await tapSheetBackdrop(page);
await expectNoSheetOpen(page);
}),
openSettings: () =>
test.step("push Settings over the New workspace screen", async () => {
await page.getByTestId("sidebar-settings").filter({ visible: true }).click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}),
openThinkingPicker: () =>
test.step("open the thinking picker in the New workspace composer", async () => {
await page
.getByRole("button", { name: /^Select thinking option/ })
.filter({ visible: true })
.click();
await expect(page.getByText("High", { exact: true })).toBeVisible();
}),
previewAttachedImage: () =>
test.step("attach an image to the New workspace composer and preview it", async () => {
await attachComposerTestImage(page);
await openImageLightbox(page);
}),
typeInHeldComposer: (text) =>
test.step(`type ${text} in the New workspace composer`, async () => {
await prompt(page).filter({ visible: true }).fill(text);
}),
expectOverlappingFocusesKeepTheLatestSelection: () =>
test.step("two overlapping focus() calls leave the later selection", async () => {
const selection = await page.evaluate(async () => {
const composer = (globalThis as HeldComposerGlobal).heldComposer!;
await Promise.all([
composer.focus({ selection: { start: 0, end: 1 } }),
composer.focus({ selection: { start: 2, end: 4 } }),
]);
const input = document.activeElement as HTMLTextAreaElement;
return [input.selectionStart, input.selectionEnd];
});
expect(selection).toEqual([2, 4]);
}),
expectHeldComposerReadyToType: () =>
test.step("nothing covers the composer and the cursor is in it", async () => {
await expect(page.getByText("High", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("attachment-lightbox-close")).toHaveCount(0);
await expect(prompt(page).filter({ visible: true })).toBeFocused();
}),
expectHeldComposerFocusResult: (result) =>
test.step(`the held composer's focus() reports ${result}`, async () => {
const actual = await page.evaluate(() =>
(globalThis as HeldComposerGlobal).heldComposer?.focus().then(
() => "focused",
(error: Error) => error.message,
),
);
expect(actual).toBe(result);
}),
});
} finally {
await client.removePlugin("held-composer");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
}
type FocusAgentGlobal = typeof globalThis & {
focusAgent?: (agentId: string) => Promise<void>;
};
export async function withPluginFocusingASubagent(
page: Page,
run: (author: {
openParentAgent(): Promise<void>;
openExplorer(): Promise<void>;
focusSubagent(): Promise<void>;
expectSubagentTabReadyToType(): Promise<void>;
keepParentInExplorerOnCompactScreen(): Promise<void>;
focusParent(): Promise<void>;
expectParentReadyToType(): Promise<void>;
coverExplorerComposerWithFileSheet(): Promise<void>;
focusParentAsAFileSheetOpens(): Promise<void>;
openSubagentPageInParentComposer(): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "focus-subagent-",
title: "Focus parent",
initialPrompt: promptMentioning(FILE_LINK),
});
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
const child = await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Focus child" },
);
const plugin = await installFocusingPlugin(page);
const { focusAgent } = plugin;
try {
await page.setViewportSize(COMPACT);
await gotoAppShell(page);
await run({
keepParentInExplorerOnCompactScreen: () =>
test.step("move the parent into the Explorer, then narrow the window", async () => {
await page.setViewportSize(WIDE);
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
// The seeded turn must finish first, so the drag isn't racing its updates to the tab.
await expect(page.getByText(FILE_LINK, { exact: true })).toBeVisible({ timeout: 15_000 });
const explorer = await ensureExplorerSidebar(page);
await dragTabIntoExplorer(page, `agent_${workspace.agentId}`);
await expect(
explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`),
).toBeVisible();
await page.setViewportSize(COMPACT);
// The phone layout hides the Explorer; focus must find it there, not in the wide layout.
await expect(explorer).toBeHidden();
}),
focusParent: () =>
test.step("focus the agent kept in the Explorer", () => focusAgent(workspace.agentId)),
expectParentReadyToType: () =>
test.step("no sheet is open and the cursor is in the composer", async () => {
await expectNoSheetOpen(page);
await expect(prompt(page).filter({ visible: true })).toBeFocused();
}),
openSubagentPageInParentComposer: () =>
test.step("open the subagent's page from the parent's Subagents track", async () => {
await openSubagentFromCompactTrack(page, child.id);
await expect(sheet(page, child.title)).toBeVisible();
}),
coverExplorerComposerWithFileSheet: () =>
test.step("open a file's sheet over the Explorer's composer", async () => {
await page.getByText(FILE_LINK, { exact: true }).filter({ visible: true }).click();
await expect(sheet(page, "linked.ts")).toBeVisible();
}),
focusParentAsAFileSheetOpens: () =>
test.step("focus the agent while a file's sheet is opening over it", async () => {
// The click looks the file up first. Focus in the commit that mounts the sheet, before
// its opening animation has started.
await page.evaluate((id) => {
const global = globalThis as FocusAgentGlobal & {
focusedAsSheetOpens?: Promise<string>;
};
global.focusedAsSheetOpens = new Promise((resolve) => {
const observer = new MutationObserver(() => {
if (!document.querySelector('[aria-label="Bottom sheet backdrop"]')) return;
observer.disconnect();
void global.focusAgent?.(id).then(
() => resolve("focused"),
(error: Error) => resolve(error.message),
);
});
observer.observe(document.body, { childList: true, subtree: true });
});
}, workspace.agentId);
await page.getByText(FILE_LINK, { exact: true }).filter({ visible: true }).click();
const outcome = await page.evaluate(
() => (globalThis as { focusedAsSheetOpens?: Promise<string> }).focusedAsSheetOpens,
);
expect(outcome).toBe("focused");
}),
openParentAgent: () =>
test.step("open the parent agent on a phone", async () => {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
await expectOpenTabCount(page, 1);
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
}),
openExplorer: () =>
test.step("open the Explorer over the parent's composer", async () => {
await expect(await ensureExplorerSidebar(page)).toBeVisible();
}),
focusSubagent: () =>
test.step("focus the subagent, which has no tab", () => focusAgent(child.id)),
expectSubagentTabReadyToType: () =>
test.step("the subagent is a tab with the cursor in its composer", async () => {
await expectNoSheetOpen(page);
await expectOpenTabCount(page, 2);
await expect(prompt(page)).toBeFocused();
}),
});
} finally {
await plugin.remove();
await workspace.cleanup();
}
}
/** A plugin that focuses any agent's composer, as a callback holding the agent's id would. */
async function installFocusingPlugin(page: Page) {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-focus-agent-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "focus-agent", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`export default function contribute(client) {
globalThis.focusAgent = (agentId) => client.composers({ agentId })[0].focus();
return () => {};
}`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
return {
focusAgent: async (agentId: string) => {
const result = await page.evaluate(
(id) =>
(globalThis as FocusAgentGlobal).focusAgent?.(id).then(
() => "focused",
(error: Error) => error.message,
),
agentId,
);
expect(result).toBe("focused");
},
remove: async () => {
await client.removePlugin("focus-agent");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
},
};
}
export async function withPluginFocusingAnAgentWithContextUsage(
page: Page,
run: (author: {
openContextUsageOnPhone(): Promise<void>;
focusAgent(): Promise<void>;
expectReadyToType(): Promise<void>;
}) => Promise<void>,
) {
const agent = await seedAgentWithContextWindow();
const plugin = await installFocusingPlugin(page);
try {
await page.setViewportSize(COMPACT);
await gotoAppShell(page);
await run({
openContextUsageOnPhone: () =>
test.step("open the agent's context usage on a phone", async () => {
await openAgentRoute(page, agent);
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
await pressContextWindowMeter(page);
}),
focusAgent: () => test.step("focus the agent", () => plugin.focusAgent(agent.agentId)),
expectReadyToType: () =>
test.step("the context usage is closed and the cursor is in the composer", async () => {
await expect(contextWindowDetails(page)).toHaveCount(0);
await expect(prompt(page).filter({ visible: true })).toBeFocused();
}),
});
} finally {
await plugin.remove();
await agent.cleanup();
}
}
+12
View File
@@ -112,3 +112,15 @@ export async function expectDeclinedDiscardPrompt(
await close();
expect(await prompt).toContain("discard the draft");
}
/** A seeded prompt the mock agent echoes, so the chat shows `target` as a tappable file link. */
export function promptMentioning(target: string): string {
return [
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].join("\n");
}
+7 -4
View File
@@ -31,10 +31,13 @@ async function expectContextMenuAtPointer(
menuTestID: string,
anchor: ContextMenuAnchor,
): Promise<void> {
const bounds = await page.getByTestId(menuTestID).boundingBox();
if (!bounds) throw new Error(`Could not measure ${menuTestID}`);
expect(Math.abs(bounds.x - anchor.x)).toBeLessThanOrEqual(2);
expect(Math.abs(bounds.y - (anchor.y + 4))).toBeLessThanOrEqual(2);
// The visible measuring surface is replaced when layout places the menu at its anchor.
await expect(async () => {
const bounds = await page.getByTestId(menuTestID).boundingBox();
if (!bounds) throw new Error(`Could not measure ${menuTestID}`);
expect(Math.abs(bounds.x - anchor.x)).toBeLessThanOrEqual(2);
expect(Math.abs(bounds.y - (anchor.y + 4))).toBeLessThanOrEqual(2);
}).toPass({ timeout: 10_000 });
}
export async function openWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
@@ -1,5 +1,6 @@
import { expect, type Page } from "@playwright/test";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { readReplicaCache } from "./replica-cache-storage";
import type { LongTimelineAgent } from "./timeline-pagination";
import {
expectReconnectingToastGone,
@@ -14,7 +15,7 @@ const SUBSCRIPTION_RESPONSE = "agent.timeline.set_subscription.response";
const TIMELINE_REQUEST = "fetch_agent_timeline_request";
const TIMELINE_RESPONSE = "fetch_agent_timeline_response";
/**
* `FRESHNESS_CHECK_QUIET_MS` is 500: a check unanswered that long reports Updating messages by
* `FRESHNESS_CHECK_QUIET_MS` is 500: an opening check unanswered that long reports Updating messages by
* design, however long the daemon takes. The app starts that wait just before it sends the question
* this helper times, so the bound stops short of 500.
*/
@@ -151,13 +152,24 @@ export async function reopenAppWatchingForUpdatingToast(page: Page): Promise<()
return () => expectToastWaitedForTheDaemon(page);
}
/** Returns to the chat with the daemon's answer held, past the point the toast reports it. */
export async function returnWithFreshnessCheckUnanswered(
/** Opens persisted history with the daemon's freshness answer held. */
export async function reopenWithFreshnessCheckUnanswered(
page: Page,
gate: DaemonWebSocketGate,
agent: LongTimelineAgent,
): Promise<void> {
// A cold open needs a persisted authoritative cursor to ask a freshness question.
await expect
.poll(async () => {
const cache = await readReplicaCache(page);
return cache?.hosts
?.flatMap((host) => host.timelines)
.find((timeline) => timeline.agentId === agent.agentId)?.range?.endSeq;
})
.toBeGreaterThan(0);
gate.holdNextClientRequest(SUBSCRIPTION_REQUEST);
await hideTabAndReturn(page);
await page.reload();
await expect(page.getByText(agent.newestPrompt, { exact: true })).toBeVisible();
await gate.waitForHeldClientRequest();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
}
@@ -61,6 +61,23 @@ function explorerSidebar(page: Page) {
return visibleTestId(page, "workspace-explorer-sidebar").first();
}
export async function dragTabIntoExplorer(page: Page, tabId: string): Promise<void> {
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
const sourceBox = await source.boundingBox();
const destinationBox = await destination.boundingBox();
if (!sourceBox || !destinationBox)
throw new Error("Tab drag requires visible source and destination");
const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 };
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x + 12, start.y + 4);
await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, {
steps: 20,
});
await page.mouse.up();
}
async function selectWorkspaceTab(tab: Locator): Promise<void> {
if ((await tab.getAttribute("aria-selected")) !== "true") {
// The close action overlays the chip's trailing edge on hover. Click the
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -4,8 +4,12 @@ import { within } from "@testing-library/dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import { ExpandableBadge } from "@/components/message";
import { page, userEvent } from "vitest/browser";
import { PlanCard } from "@/components/plan-card";
import { MessagePreview } from "@/components/user-message";
import { createUserMessageExpansion } from "@/components/user-message/expansion";
import { UserMessageExpansionContext } from "@/components/user-message/expansion-context";
import { DisclosureRow } from "./disclosure";
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
@@ -72,26 +76,19 @@ const view = () => within(container);
const preLine = { whiteSpace: "pre-line" } as const;
async function waitForLayout() {
await act(async () => {
for (let frame = 0; frame < 2; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
});
}
describe("timeline disclosures hold their row", () => {
it("routes user message show more and show less through the row's hold", async () => {
it("routes user message show more and show less through the row's hold", () => {
const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n");
const expansion = createUserMessageExpansion();
const holds = mountRow(
"message-1",
() => view().queryByRole("button", { name: "Show less" }) !== null,
<MessagePreview>
<span style={preLine}>{longText}</span>
</MessagePreview>,
<UserMessageExpansionContext.Provider value={expansion}>
<MessagePreview itemId="message-1">
<span style={preLine}>{longText}</span>
</MessagePreview>
</UserMessageExpansionContext.Provider>,
);
// The preview learns it overflows from its first layout.
await waitForLayout();
act(() => view().getByRole("button", { name: "Show more" }).click());
expect(view().getByRole("button", { name: "Show less" })).toBeTruthy();
@@ -145,3 +142,63 @@ describe("timeline disclosures hold their row", () => {
expect(isExpanded()).toBe(true);
});
});
function renderFileDetails() {
return <div>File contents</div>;
}
describe("tool badge file action", () => {
it("keeps file opening separate from the accessible disclosure button", async () => {
let opened = 0;
function Badge({ hasFile }: { hasFile: boolean }) {
const [expanded, setExpanded] = React.useState(false);
const toggle = React.useCallback(() => setExpanded(!expanded), [expanded]);
const openFile = React.useCallback(() => {
opened += 1;
}, []);
return (
<ExpandableBadge
label="Read"
secondaryLabel="src/index.ts"
isExpanded={expanded}
onToggle={toggle}
onOpenFile={hasFile ? openFile : undefined}
renderDetails={renderFileDetails}
/>
);
}
container.style.width = "300px";
container.style.marginLeft = "20px";
act(() => root.render(<Badge hasFile={false} />));
const header = page.getByRole("button", { name: "Read src/index.ts", exact: true });
const headerElement = view().getByRole("button", { name: /^Read src\/index\.ts$/ });
expect(headerElement.getBoundingClientRect().width).toBeGreaterThan(260);
await header.click({ position: { x: 220, y: 10 } });
await expect.element(page.getByText("File contents")).toBeVisible();
await header.click({ position: { x: 2, y: 2 } });
await expect.element(page.getByText("File contents")).not.toBeInTheDocument();
await header.click({ position: { x: 2, y: 2 } });
await expect.element(page.getByText("File contents")).toBeVisible();
await header.click({ position: { x: 220, y: 10 } });
await expect.element(page.getByText("File contents")).not.toBeInTheDocument();
act(() => root.render(<Badge hasFile />));
await page.getByRole("button", { name: "Read src/index.ts", exact: true }).hover();
const file = view().getByRole("button", { name: /^Open file$/ });
expect(file.parentElement?.closest("button")).toBeNull();
await page.getByRole("button", { name: "Open file", exact: true }).click();
expect(opened).toBe(1);
const disclosure = view().getByRole("button", { name: /^Read src\/index\.ts$/ });
expect(view().queryByText("File contents")).toBeNull();
await header.click({ position: { x: 220, y: 10 } });
await expect.element(page.getByText("File contents")).toBeVisible();
expect(opened).toBe(1);
act(() => file.focus());
await userEvent.keyboard("{Enter}");
expect(opened).toBe(2);
expect(view().getByText("File contents")).toBeTruthy();
act(() => disclosure.focus());
await userEvent.keyboard("{Enter}");
expect(view().queryByText("File contents")).toBeNull();
});
});
+34 -26
View File
@@ -66,6 +66,10 @@ import {
useAgentToolCallExpansion,
useExpandedToolCallGroups,
} from "@/tool-calls/expansion-context";
import {
UserMessageExpansionContext,
useAgentUserMessageExpansion,
} from "@/components/user-message/expansion-context";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { createStreamPresentation } from "./presentation";
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
@@ -382,6 +386,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
new Set(),
);
const toolCallExpansion = useAgentToolCallExpansion(agentId);
const userMessageExpansion = useAgentUserMessageExpansion(agentId);
const expandedToolCallGroups = useExpandedToolCallGroups(toolCallExpansion);
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
@@ -711,6 +716,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
return (
<UserMessage
itemId={item.id}
serverId={resolvedServerId}
agentId={agentId}
messageId={item.messageId}
@@ -1169,32 +1175,34 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
<ToolCallExpansionContext.Provider value={toolCallExpansion}>
{streamRenderStrategy.render({
readingRowReplacements: presentation.readingRowReplacements,
agentId,
segments: renderModel.segments,
historyRowRevision,
liveHeadRowRevision: expandedToolCallGroups,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: nearBottom.set,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
})}
<UserMessageExpansionContext.Provider value={userMessageExpansion}>
{streamRenderStrategy.render({
readingRowReplacements: presentation.readingRowReplacements,
agentId,
segments: renderModel.segments,
historyRowRevision,
liveHeadRowRevision: expandedToolCallGroups,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: nearBottom.set,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
})}
</UserMessageExpansionContext.Provider>
</ToolCallExpansionContext.Provider>
</MessageOuterSpacingProvider>
<ChatOutlineRail
@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo } from "react";
import { memo, useCallback, useMemo, useState } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ExternalLink, Gift } from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -156,9 +156,19 @@ interface ReleaseViewProps {
isCurrent: boolean;
}
/**
* A release that has highlights shows only them until the reader asks for the
* sections, which are the full record. A release without highlights has nothing
* to stand in for its sections, so it shows them as it always did.
*/
const ReleaseView = memo(function ReleaseView({ release, isCurrent }: ReleaseViewProps) {
const { t } = useTranslation();
const date = formatChangelogDate(release.date);
const [showingAllChanges, setShowingAllChanges] = useState(false);
const toggleAllChanges = useCallback(() => setShowingAllChanges((value) => !value), []);
const allChangesState = useMemo(() => ({ expanded: showingAllChanges }), [showingAllChanges]);
const hasHighlights = release.highlights.length > 0;
const showsSections = !hasHighlights || showingAllChanges;
return (
<View style={styles.release} testID={`changelog-release-${release.version}`}>
@@ -168,26 +178,40 @@ const ReleaseView = memo(function ReleaseView({ release, isCurrent }: ReleaseVie
<View style={styles.headingSpacer} />
{date ? <Text style={styles.date}>{date}</Text> : null}
</View>
{keyChangelogSections(release.sections).map(({ key, section }) => (
<View key={key} style={styles.section}>
{section.title ? <Text style={styles.sectionTitle}>{section.title}</Text> : null}
{section.body ? <MarkdownRenderer text={section.body} compact /> : null}
</View>
))}
{hasHighlights ? <MarkdownRenderer text={release.highlights} compact /> : null}
{hasHighlights && release.sections.length > 0 ? (
<Button
variant="ghost"
style={styles.allChangesToggle}
accessibilityState={allChangesState}
onPress={toggleAllChanges}
>
{t(showingAllChanges ? "changelog.hideAllChanges" : "changelog.showAllChanges")}
</Button>
) : null}
{showsSections ? <ReleaseSections sections={release.sections} /> : null}
</View>
);
});
function ReleaseSections({ sections }: { sections: readonly ChangelogSection[] }) {
return keyChangelogSections(sections).map(({ key, section }) => (
<View key={key} style={styles.section}>
{section.title ? <Text style={styles.sectionTitle}>{section.title}</Text> : null}
{section.body ? <MarkdownRenderer text={section.body} compact /> : null}
</View>
));
}
/** A release can repeat a section title; the occurrence keeps the key stable. */
function keyChangelogSections(
sections: readonly ChangelogSection[],
): { key: string; section: ChangelogSection }[] {
const seen = new Map<string, number>();
return sections.map((section) => {
const title = section.title ?? "";
const occurrence = seen.get(title) ?? 0;
seen.set(title, occurrence + 1);
return { key: `${title}:${occurrence}`, section };
const occurrence = seen.get(section.title) ?? 0;
seen.set(section.title, occurrence + 1);
return { key: `${section.title}:${occurrence}`, section };
});
}
@@ -236,6 +260,12 @@ const styles = StyleSheet.create((theme) => ({
textTransform: "uppercase",
letterSpacing: 0.5,
},
// Flush with the highlights above it, so the label's ink sits on their rail.
allChangesToggle: {
alignSelf: "flex-start",
paddingHorizontal: 0,
borderWidth: 0,
},
showMore: {
alignSelf: "center",
},
@@ -8,15 +8,15 @@ import {
type ChangelogSection,
} from "./parse-changelog";
function bodies(markdown: string, releaseIndex = 0): string[] {
return parseChangelog(markdown)[releaseIndex].sections.map(sectionBody);
function bodies(markdown: string): string[] {
return parseChangelog(markdown)[0].sections.map(sectionBody);
}
function sectionBody(section: ChangelogSection): string {
return section.body;
}
function sectionTitle(section: ChangelogSection): string | null {
function sectionTitle(section: ChangelogSection): string {
return section.title;
}
@@ -25,17 +25,36 @@ function releaseDate(release: ChangelogRelease): string {
}
function authoredLines(release: ChangelogRelease): string[] {
return release.sections.flatMap(sectionLines);
return [...release.highlights.split("\n"), ...release.sections.flatMap(sectionLines)];
}
function sectionLines(section: ChangelogSection): string[] {
return [...(section.title ? [`### ${section.title}`] : []), ...section.body.split("\n")];
return [`### ${section.title}`, ...section.body.split("\n")];
}
function isNotBlank(line: string): boolean {
return line.trim().length > 0;
}
function readRepositoryFile(path: string): string {
return readFileSync(fileURLToPath(new URL(`../../../../../${path}`, import.meta.url)), "utf8");
}
/** The cases the app, the website and the release scripts must all split the same way. */
interface ChangelogStructureCases {
document: string[];
releases: {
version: string;
date: string;
highlights: string[];
sectionTitles: string[];
}[];
}
const structureCases: ChangelogStructureCases = JSON.parse(
readRepositoryFile("scripts/test-support/changelog-structure.json"),
);
describe("parseChangelog", () => {
it("reads the shape the changelog is authored in today", () => {
const releases = parseChangelog(
@@ -44,7 +63,8 @@ describe("parseChangelog", () => {
"",
"## 0.8.0-beta.1 - 2026-09-08",
"",
"This release extends what plugins can do.",
"- **Plugin providers:** Plugins can add providers.",
"- **Faster diffs:** Diffs load sooner.",
"",
"### Plugins",
"",
@@ -67,8 +87,9 @@ describe("parseChangelog", () => {
{
version: "0.8.0-beta.1",
date: "2026-09-08",
highlights:
"- **Plugin providers:** Plugins can add providers.\n- **Faster diffs:** Diffs load sooner.",
sections: [
{ title: null, body: "This release extends what plugins can do." },
{
title: "Plugins",
body: "- Added provider contributions ([#4314](https://example.com/4314))",
@@ -79,6 +100,7 @@ describe("parseChangelog", () => {
{
version: "0.7.2",
date: "2026-09-02",
highlights: "",
sections: [{ title: "Fixed", body: "- Fixed a thing" }],
},
]);
@@ -89,7 +111,7 @@ describe("parseChangelog", () => {
"# Changelog\n\nAll notable changes.\n\n## 1.0.0 - 2026-01-01\n\n- One\n",
);
expect(releases).toHaveLength(1);
expect(releases[0].sections).toEqual([{ title: null, body: "- One" }]);
expect(releases[0]).toMatchObject({ highlights: "- One", sections: [] });
});
it("returns nothing for a document with no releases", () => {
@@ -136,13 +158,13 @@ describe("parseChangelog", () => {
it("does not treat a deeper heading as a release", () => {
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n#### Deep\n\n- One\n");
expect(releases).toHaveLength(1);
expect(releases[0].sections).toEqual([{ title: null, body: "#### Deep\n\n- One" }]);
expect(releases[0].highlights).toBe("#### Deep\n\n- One");
});
it("does not treat an unspaced hash run as a heading", () => {
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n##notaheading\n");
expect(releases).toHaveLength(1);
expect(releases[0].sections[0].body).toBe("##notaheading");
expect(releases[0].highlights).toBe("##notaheading");
});
});
@@ -162,14 +184,34 @@ describe("parseChangelog", () => {
]);
});
it("drops an empty lead section", () => {
it("keeps a bare section heading as an untitled section", () => {
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n###\n\n- One\n");
expect(releases[0].sections).toEqual([{ title: "", body: "- One" }]);
});
});
describe("highlights", () => {
it("has none when the release opens with a section", () => {
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n### Fixed\n\n- One\n");
expect(releases[0].sections).toEqual([{ title: "Fixed", body: "- One" }]);
expect(releases[0]).toMatchObject({
highlights: "",
sections: [{ title: "Fixed", body: "- One" }],
});
});
it("reads the summary paragraphs older entries opened with as highlights", () => {
const releases = parseChangelog(
"## 1.0.0 - 2026-01-01\n\nDaemon restarts keep terminals.\n\nMore here.\n\n### Added\n\n- One\n",
);
expect(releases[0]).toMatchObject({
highlights: "Daemon restarts keep terminals.\n\nMore here.",
sections: [{ title: "Added", body: "- One" }],
});
});
it("keeps a release that is only prose", () => {
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\nJust a note.\n");
expect(releases[0].sections).toEqual([{ title: null, body: "Just a note." }]);
expect(releases[0]).toMatchObject({ highlights: "Just a note.", sections: [] });
});
});
@@ -256,7 +298,12 @@ describe("parseChangelog", () => {
it("reads CRLF documents", () => {
const releases = parseChangelog("## 1.0.0 - 2026-01-01\r\n\r\n### Fixed\r\n\r\n- One\r\n");
expect(releases).toEqual([
{ version: "1.0.0", date: "2026-01-01", sections: [{ title: "Fixed", body: "- One" }] },
{
version: "1.0.0",
date: "2026-01-01",
highlights: "",
sections: [{ title: "Fixed", body: "- One" }],
},
]);
});
});
@@ -272,19 +319,45 @@ describe("formatChangelogDate", () => {
});
});
describe("the structure every changelog surface shares", () => {
for (const lineEnding of ["\n", "\r\n"]) {
it(`splits the shared cases with ${JSON.stringify(lineEnding)} line endings`, () => {
const releases = parseChangelog(structureCases.document.join(lineEnding));
expect(
releases.map((release) => ({
version: release.version,
date: release.date,
highlights: release.highlights.split("\n").filter(isNotBlank),
sectionTitles: release.sections.map(sectionTitle),
})),
).toEqual(
structureCases.releases.map((release) => ({
version: release.version,
date: release.date,
highlights: release.highlights.filter(isNotBlank),
sectionTitles: release.sectionTitles,
})),
);
});
}
});
describe("the repository's own CHANGELOG.md", () => {
const markdown = readFileSync(
fileURLToPath(new URL("../../../../../CHANGELOG.md", import.meta.url)),
"utf8",
);
const markdown = readRepositoryFile("CHANGELOG.md");
const releases = parseChangelog(markdown);
it("finds a release for every release heading line", () => {
const headingLines = markdown.split("\n").filter((line) => line.startsWith("## "));
expect(releases).toHaveLength(headingLines.length);
});
it("parses every release heading", () => {
expect(releases.length).toBeGreaterThan(50);
for (const release of releases) {
expect(release.version).toMatch(/^\d+\.\d+\.\d+/);
expect(release.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(release.sections.length).toBeGreaterThan(0);
expect(release.highlights.length + release.sections.length).toBeGreaterThan(0);
}
});
@@ -3,7 +3,10 @@
*
* The document is authored in the repository, fetched at runtime, and rewritten
* on every release, so this parser commits to exactly two facts: a `##` heading
* starts a release and a `###` heading starts a section inside it. Everything
* starts a release and a `###` heading starts a section inside it. Whatever sits
* between the release heading and its first section is the release's
* highlights: the list a release now leads with, or the summary paragraphs and
* notices older entries opened with. Everything
* else — prose, callouts, block quotes, fenced code, images, video and embed
* HTML, tables, nested lists — is carried through verbatim and handed to the
* Markdown renderer. A new block kind in the changelog therefore needs no
@@ -17,8 +20,8 @@
*/
export interface ChangelogSection {
/** The `###` heading text, or null for content that precedes the first one. */
title: string | null;
/** The `###` heading text. Empty for a bare `###`. */
title: string;
/** Verbatim markdown under the heading. */
body: string;
}
@@ -28,7 +31,9 @@ export interface ChangelogRelease {
version: string;
/** The heading tail after the version. Empty when the heading carried none. */
date: string;
/** Sections in document order; the untitled lead section comes first. */
/** Verbatim markdown before the first section. Empty when the release opens with one. */
highlights: string;
/** The `###` sections in document order. */
sections: ChangelogSection[];
}
@@ -46,13 +51,14 @@ const LEADING_V = /^v(?=\d)/i;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
interface SectionDraft {
title: string | null;
title: string;
lines: string[];
}
interface ReleaseDraft {
version: string;
date: string;
highlights: string[];
sections: SectionDraft[];
}
@@ -75,7 +81,8 @@ export function parseChangelog(markdown: string): ChangelogRelease[] {
if (releaseHeading) {
releases.push({
...splitReleaseHeading(releaseHeading[1] ?? ""),
sections: [{ title: null, lines: [] }],
highlights: [],
sections: [],
});
continue;
}
@@ -83,7 +90,7 @@ export function parseChangelog(markdown: string): ChangelogRelease[] {
const sectionHeading = line.match(SECTION_HEADING);
if (sectionHeading && releases.length > 0) {
releases[releases.length - 1].sections.push({
title: (sectionHeading[1] ?? "").replace(ATX_CLOSING, "").trim() || null,
title: (sectionHeading[1] ?? "").replace(ATX_CLOSING, "").trim(),
lines: [],
});
continue;
@@ -91,19 +98,22 @@ export function parseChangelog(markdown: string): ChangelogRelease[] {
}
const release = releases.at(-1);
if (release) release.sections[release.sections.length - 1].lines.push(line);
if (release) (release.sections.at(-1)?.lines ?? release.highlights).push(line);
}
return releases.map(materializeRelease);
}
function materializeRelease(draft: ReleaseDraft): ChangelogRelease {
const sections = draft.sections
.map((section) => ({ title: section.title, body: trimBlankLines(section.lines) }))
// A heading with an empty body still names something; an empty lead section
// is just the gap between the release heading and the first section.
.filter((section) => section.title !== null || section.body.length > 0);
return { version: draft.version, date: draft.date, sections };
return {
version: draft.version,
date: draft.date,
highlights: trimBlankLines(draft.highlights),
sections: draft.sections.map((section) => ({
title: section.title,
body: trimBlankLines(section.lines),
})),
};
}
function trimBlankLines(lines: string[]): string {
@@ -28,6 +28,7 @@ import { isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { AdaptiveTextInput } from "@/components/adaptive-text-input";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input";
// Horizontal indent token shared by the sheet header (title, back arrow,
@@ -654,6 +655,9 @@ export function AdaptiveModalSheet({
const isMobile = useIsCompactFormFactor();
const isKeyboardVisible = useKeyboardVisibility(visible);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const isSheet = isMobile || !isWeb;
// The sheet registers itself; the desktop dialog does not use it.
useDismissibleOverlay(visible && !isSheet, onClose);
const footerView = useMemo(
() => (footer ? <SheetFooter style={footerContainerStyle}>{footer}</SheetFooter> : null),
[footer, footerContainerStyle],
@@ -668,7 +672,7 @@ export function AdaptiveModalSheet({
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible,
isEnabled: isMobile || !isWeb,
isEnabled: isSheet,
onClose,
});
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
+41 -60
View File
@@ -92,6 +92,8 @@ export type { InlinePathTarget } from "@/assistant-file-links";
export type { AssistantForkTarget };
interface UserMessageProps {
/** The timeline item's id, which keeps the message's expanded state. */
itemId: string;
sender?: AgentMessage["sender"];
serverId?: string;
agentId?: string;
@@ -391,6 +393,7 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
const MESSAGE_TEXT_DATASET = { messageText: "true" };
export const UserMessage = memo(function UserMessage({
itemId,
sender,
serverId,
agentId,
@@ -480,7 +483,7 @@ export const UserMessage = memo(function UserMessage({
>
{sender ? <MessageSender sender={sender} serverId={serverId} /> : null}
<View style={userMessageStylesheet.bubble}>
<MessagePreview>
<MessagePreview itemId={itemId}>
{hasImages ? (
<View style={imagePreviewContainerStyle}>
{images.map((image) => (
@@ -922,13 +925,19 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
overflow: "hidden",
},
pressablePressed: {
opacity: 0.9,
},
disclosureButton: {
flex: 1,
minWidth: 0,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
flexDirection: "row",
alignItems: "center",
},
headerRow: {
flexDirection: "row",
alignItems: "center",
@@ -984,15 +993,12 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
flexShrink: 0,
},
openFileButton: {
marginRight: theme.spacing[2],
marginLeft: theme.spacing[1],
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
openFileButtonPlaceholderIcon: {
width: 14,
height: 14,
},
detailWrapper: {
borderBottomLeftRadius: theme.borderRadius.lg,
borderBottomRightRadius: theme.borderRadius.lg,
@@ -1598,7 +1604,6 @@ interface ExpandableBadgeWebShimmerOverlayProps {
secondaryLabel?: string;
shimmerLabelTextStyle: StyleProp<TextStyle>;
shimmerSecondaryTextStyle: StyleProp<TextStyle>;
showOpenFileButton: boolean;
}
function ExpandableBadgeWebShimmerOverlay({
@@ -1606,7 +1611,6 @@ function ExpandableBadgeWebShimmerOverlay({
secondaryLabel,
shimmerLabelTextStyle,
shimmerSecondaryTextStyle,
showOpenFileButton,
}: ExpandableBadgeWebShimmerOverlayProps) {
return (
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
@@ -1618,14 +1622,7 @@ function ExpandableBadgeWebShimmerOverlay({
{secondaryLabel}
</Text>
) : null}
{showOpenFileButton ? (
<View style={expandableBadgeStylesheet.openFileButton}>
<View style={expandableBadgeStylesheet.openFileButtonPlaceholderIcon} />
</View>
) : null}
{!secondaryLabel && !showOpenFileButton ? (
<View style={expandableBadgeStylesheet.spacer} />
) : null}
{!secondaryLabel ? <View style={expandableBadgeStylesheet.spacer} /> : null}
</View>
);
}
@@ -1649,11 +1646,6 @@ interface ExpandableBadgeLabelRowProps {
onLabelRowLayout: (event: LayoutChangeEvent) => void;
onLabelLayout: (event: LayoutChangeEvent) => void;
onSecondaryLayout: (event: LayoutChangeEvent) => void;
showOpenFileButton: boolean;
isOpenFileHovered: boolean;
onOpenFilePress: (event: GestureResponderEvent) => void;
onOpenFileHoverIn: () => void;
onOpenFileHoverOut: () => void;
}
function ExpandableBadgeLabelRow({
@@ -1675,13 +1667,7 @@ function ExpandableBadgeLabelRow({
onLabelRowLayout,
onLabelLayout,
onSecondaryLayout,
showOpenFileButton,
isOpenFileHovered,
onOpenFilePress,
onOpenFileHoverIn,
onOpenFileHoverOut,
}: ExpandableBadgeLabelRowProps) {
const { t } = useTranslation();
return (
<View
style={expandableBadgeStylesheet.labelRow}
@@ -1700,30 +1686,12 @@ function ExpandableBadgeLabelRow({
shouldMeasureWebShimmer={shouldMeasureWebShimmer}
onSecondaryLayout={onSecondaryLayout}
/>
{showOpenFileButton ? (
<Pressable
onPress={onOpenFilePress}
onHoverIn={onOpenFileHoverIn}
onHoverOut={onOpenFileHoverOut}
accessibilityRole="button"
accessibilityLabel={t("message.actions.openFile")}
testID="tool-call-open-file"
style={expandableBadgeStylesheet.openFileButton}
hitSlop={6}
>
<ThemedFileSymlinkIcon
size={14}
uniProps={isOpenFileHovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
</Pressable>
) : null}
{isWebShimmer ? (
<ExpandableBadgeWebShimmerOverlay
label={label}
secondaryLabel={secondaryLabel}
shimmerLabelTextStyle={shimmerLabelTextStyle}
shimmerSecondaryTextStyle={shimmerSecondaryTextStyle}
showOpenFileButton={showOpenFileButton}
/>
) : null}
{isNativeShimmer ? (
@@ -1928,6 +1896,7 @@ export const ExpandableBadge = memo(function ExpandableBadge({
borderlessWhenExpanded = false,
testID,
}: ExpandableBadgeProps) {
const { t } = useTranslation();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isHovered, setIsHovered] = useState(false);
const [isOpenFileHovered, setIsOpenFileHovered] = useState(false);
@@ -2190,13 +2159,13 @@ export const ExpandableBadge = memo(function ExpandableBadge({
onPointerEnter={isWeb ? handleHoverIn : undefined}
onPointerLeave={isWeb ? handleHoverOut : undefined}
>
<Pressable
{...pressHandlers}
disabled={!isInteractive}
accessibilityState={accessibilityState}
style={pressableStyle}
>
<View style={expandableBadgeStylesheet.headerRow}>
<View style={[pressableStyle, expandableBadgeStylesheet.headerRow]}>
<Pressable
{...pressHandlers}
disabled={!isInteractive}
accessibilityState={accessibilityState}
style={expandableBadgeStylesheet.disclosureButton}
>
<View style={expandableBadgeStylesheet.iconBadge}>{iconSlotNode}</View>
<ExpandableBadgeLabelRow
label={label}
@@ -2217,14 +2186,26 @@ export const ExpandableBadge = memo(function ExpandableBadge({
onLabelRowLayout={handleLabelRowLayout}
onLabelLayout={handleLabelLayout}
onSecondaryLayout={handleSecondaryLayout}
showOpenFileButton={Boolean(onOpenFile && isHovered)}
isOpenFileHovered={isOpenFileHovered}
onOpenFilePress={handleOpenFilePress}
onOpenFileHoverIn={handleOpenFileHoverIn}
onOpenFileHoverOut={handleOpenFileHoverOut}
/>
</View>
</Pressable>
</Pressable>
{onOpenFile && isHovered ? (
<Pressable
onPress={handleOpenFilePress}
onHoverIn={handleOpenFileHoverIn}
onHoverOut={handleOpenFileHoverOut}
accessibilityRole="button"
accessibilityLabel={t("message.actions.openFile")}
testID="tool-call-open-file"
style={expandableBadgeStylesheet.openFileButton}
hitSlop={6}
>
<ThemedFileSymlinkIcon
size={14}
uniProps={isOpenFileHovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
</Pressable>
) : null}
</View>
{detailContent ? (
<Pressable
ref={detailWrapperRef}
@@ -17,6 +17,7 @@ import {
useEffect,
useRef,
type ReactElement,
type ReactNode,
type MutableRefObject,
type Ref,
type ComponentProps,
@@ -35,7 +36,7 @@ import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useSidebarWorkspaceRename } from "@/workspace-rename";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
@@ -287,6 +288,8 @@ interface WorkspaceRowInnerProps {
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
/** Present while the row is renamed in place: the row stops pressing, dragging and hovering. */
titleEditor?: ReactNode;
onMarkAsRead?: () => void;
onMarkAsUnread?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
@@ -1075,6 +1078,7 @@ function WorkspaceRowInner({
onCopyBranchName,
onCopyPath,
onRename,
titleEditor = null,
onMarkAsRead,
onMarkAsUnread,
archiveShortcutKeys,
@@ -1116,9 +1120,14 @@ function WorkspaceRowInner({
}, [interaction]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const isEditingTitle = titleEditor !== null;
return (
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
<SidebarWorkspaceRowFrame
workspace={workspace}
isDragging={isDragging}
isEditingTitle={isEditingTitle}
>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
@@ -1132,7 +1141,7 @@ function WorkspaceRowInner({
return (
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
@@ -1158,15 +1167,16 @@ function WorkspaceRowInner({
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
enabled={!isEditingTitle}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handleWorkspacePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={handleWorkspacePressOut}
onPress={handlePress}
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
@@ -1182,6 +1192,7 @@ function WorkspaceRowInner({
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
titleEditor={titleEditor}
>
<WorkspaceRowRightGroup
workspace={workspace}
@@ -1251,7 +1262,7 @@ function WorkspaceRowWithMenu({
const { t } = useTranslation();
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const rename = useSidebarWorkspaceRename(workspace);
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -1287,14 +1298,6 @@ function WorkspaceRowWithMenu({
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
const handleOpenRename = useCallback(() => {
setIsRenameOpen(true);
}, []);
const handleCloseRename = useCallback(() => {
setIsRenameOpen(false);
}, []);
const isPinned = workspace.pinnedAt != null;
const handleTogglePin = useCallback(() => {
onToggleWorkspacePin(workspace);
@@ -1352,7 +1355,8 @@ function WorkspaceRowWithMenu({
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={handleOpenRename}
onRename={rename.begin}
titleEditor={rename.titleEditor}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
@@ -1360,12 +1364,7 @@ function WorkspaceRowWithMenu({
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
/>
<WorkspaceRenameModal
visible={isRenameOpen}
workspace={workspace}
onClose={handleCloseRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
{rename.modal}
</>
);
}
@@ -40,7 +40,7 @@ import {
CircleX,
} from "lucide-react-native";
import { useToast } from "@/contexts/toast-context";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useSidebarWorkspaceRename } from "@/workspace-rename";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
@@ -637,7 +637,7 @@ function StatusWorkspaceRowWithMenu({
const { t } = useTranslation();
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const rename = useSidebarWorkspaceRename(workspace);
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
@@ -674,8 +674,6 @@ function StatusWorkspaceRowWithMenu({
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
const isPinned = workspace.pinnedAt != null;
const handleTogglePin = useCallback(() => {
onToggleWorkspacePin(workspace);
@@ -728,7 +726,8 @@ function StatusWorkspaceRowWithMenu({
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={handleOpenRename}
onRename={rename.begin}
titleEditor={rename.titleEditor}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
@@ -740,12 +739,7 @@ function StatusWorkspaceRowWithMenu({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
<WorkspaceRenameModal
visible={isRenameOpen}
workspace={workspace}
onClose={handleCloseRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
{rename.modal}
</>
);
}
@@ -767,6 +761,8 @@ interface StatusWorkspaceRowInnerProps {
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
/** Present while the row is renamed in place: the row stops pressing, dragging and hovering. */
titleEditor?: ReactNode;
onMarkAsRead?: () => void;
onMarkAsUnread?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
@@ -814,6 +810,7 @@ function StatusWorkspaceRowInnerContent({
onCopyBranchName,
onCopyPath,
onRename,
titleEditor = null,
onMarkAsRead,
onMarkAsUnread,
archiveShortcutKeys,
@@ -842,6 +839,7 @@ function StatusWorkspaceRowInnerContent({
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const isEditingTitle = titleEditor !== null;
const didLongPressRef = dragInteraction?.didLongPressRef;
const startDragPress = dragInteraction?.handlePressIn;
const moveDragPress = dragInteraction?.handleTouchMove;
@@ -866,7 +864,11 @@ function StatusWorkspaceRowInnerContent({
}, [endDragPress]);
return (
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
<SidebarWorkspaceRowFrame
workspace={workspace}
isDragging={isDragging}
isEditingTitle={isEditingTitle}
>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
@@ -895,7 +897,7 @@ function StatusWorkspaceRowInnerContent({
return (
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
@@ -922,14 +924,15 @@ function StatusWorkspaceRowInnerContent({
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
enabled={!isEditingTitle}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handlePressIn}
onTouchMove={moveDragPress}
onPressOut={handlePressOut}
onPress={handlePress}
onPressIn={isEditingTitle ? undefined : handlePressIn}
onTouchMove={isEditingTitle ? undefined : moveDragPress}
onPressOut={isEditingTitle ? undefined : handlePressOut}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
@@ -944,6 +947,7 @@ function StatusWorkspaceRowInnerContent({
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
titleEditor={titleEditor}
>
{renderSlot ? (
<StatusWorkspaceActionSlot
@@ -46,10 +46,13 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
export function SidebarWorkspaceRowFrame({
workspace,
isDragging = false,
isEditingTitle = false,
children,
}: {
workspace: SidebarWorkspaceEntry;
isDragging?: boolean;
/** Hover would fade the kebab in over the title being edited and open the hover card. */
isEditingTitle?: boolean;
children: (input: {
isHovered: boolean;
contextMenuOpen: boolean;
@@ -77,10 +80,10 @@ export function SidebarWorkspaceRowFrame({
workspace={workspace}
prHint={workspace.prHint}
isDragging={isDragging}
disabled={contextMenuOpen}
disabled={contextMenuOpen || isEditingTitle}
>
{children({
isHovered: isHovered && !contextMenuOpen && !isDragging,
isHovered: isHovered && !contextMenuOpen && !isDragging && !isEditingTitle,
contextMenuOpen,
onContextMenuOpenChange: handleContextMenuOpenChange,
hoverHandlers,
@@ -102,6 +105,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
shortcutNumber = null,
showShortcutBadge = false,
reserveIdleStatusIndicatorSpace = true,
titleEditor = null,
children,
}: {
workspace: SidebarWorkspaceEntry;
@@ -119,6 +123,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
showShortcutBadge?: boolean;
/** Keep the empty leading slot when the workspace has no active status. */
reserveIdleStatusIndicatorSpace?: boolean;
/** Takes the title's place, in a box exactly one title line tall, while renaming in place. */
titleEditor?: ReactNode;
children?: ReactNode;
}) {
const {
@@ -160,13 +166,17 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
)}
<View style={styles.workspaceContentColumn}>
<View style={styles.workspaceTitleRow}>
<Text
style={workspaceBranchTextStyle}
numberOfLines={1}
testID={`sidebar-workspace-title-${workspace.workspaceKey}`}
>
{workspaceLabel}
</Text>
{titleEditor ? (
<View style={styles.workspaceTitleEditorSlot}>{titleEditor}</View>
) : (
<Text
style={workspaceBranchTextStyle}
numberOfLines={1}
testID={`sidebar-workspace-title-${workspace.workspaceKey}`}
>
{workspaceLabel}
</Text>
)}
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
</View>
<WorkspaceMetaRow
@@ -557,6 +567,11 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
workspaceTitleEditorSlot: {
height: TITLE_LINE_HEIGHT,
flex: 1,
minWidth: 0,
},
workspaceBranchTextCreating: {
opacity: 0.92,
},
@@ -1,524 +0,0 @@
import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { useToast } from "@/contexts/toast-context";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import {
SidebarWorkspaceContextMenu,
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
resolveTrailingActionVisibility,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row";
import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
interface SidebarWorkspaceRowProps {
workspace: SidebarWorkspaceEntry;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
canCopyBranchName: boolean;
onPress: () => void;
/** The host pill after the title. Absent → the sidebar spans one host, or this one is hidden. */
hostBadge?: HostBadgeModel | null;
/** Project grouping only: shows a transient "creating" affordance. */
isCreating?: boolean;
/** Project grouping only: drag-to-reorder wiring. Absent → not draggable. */
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
export function SidebarWorkspaceRow({
workspace,
selected,
shortcutNumber,
showShortcutBadge,
canCopyBranchName,
onPress,
hostBadge,
isCreating = false,
drag,
isDragging = false,
dragHandleProps,
}: SidebarWorkspaceRowProps) {
const { t } = useTranslation();
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selected
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
: null,
});
}, [selected, workspace]);
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
const handleArchive = useCallback(() => {
if (isArchiving) {
return;
}
archiveController.archive();
}, [archiveController, isArchiving]);
const clipboard = useWorkspaceClipboardActions();
const handleCopyPath = useCallback(() => {
clipboard.copyPath(workspace);
}, [clipboard, workspace]);
const handleCopyBranchName = useCallback(() => {
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
const handleOpenRename = useCallback(() => {
setIsRenameOpen(true);
}, []);
const handleCloseRename = useCallback(() => {
setIsRenameOpen(false);
}, []);
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } =
useWorkspaceReadState({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
const handleMarkAsRead = useCallback(() => {
void clearAttention().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
});
}, [clearAttention, toast]);
const handleMarkAsUnread = useCallback(() => {
void markUnread().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread");
});
}, [markUnread, toast]);
useKeyboardActionHandler({
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
handleArchive();
return true;
},
});
return (
<>
<WorkspaceRowBody
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
hostBadge={hostBadge}
isCreating={isCreating}
isArchiving={isArchiving}
onPress={onPress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={isArchiving ? "pending" : "idle"}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<WorkspaceRenameModal
visible={isRenameOpen}
workspace={workspace}
onClose={handleCloseRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
interface WorkspaceRowBodyProps {
workspace: SidebarWorkspaceEntry;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
hostBadge?: HostBadgeModel | null;
isCreating: boolean;
isArchiving: boolean;
onPress: () => void;
drag?: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onMarkAsUnread?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}
function WorkspaceRowBody({
workspace,
selected,
shortcutNumber,
showShortcutBadge,
hostBadge,
isCreating,
isArchiving,
onPress,
drag,
isDragging,
dragHandleProps,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
onMarkAsRead,
onMarkAsUnread,
archiveShortcutKeys,
}: WorkspaceRowBodyProps) {
const isCompact = useIsCompactFormFactor();
const isTouchPlatform = platformIsNative || isCompact;
const [isPressed, setIsPressed] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const draggable = Boolean(drag);
const interaction = useLongPressDragInteraction({
drag,
menuController: null,
});
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragAttributes
} = dragHandleProps?.attributes ?? {};
const handlePress = useCallback(() => {
if (interaction.didLongPressRef.current) {
interaction.didLongPressRef.current = false;
return;
}
onPress();
}, [interaction.didLongPressRef, onPress]);
const handleWorkspacePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
if (draggable) interaction.handlePressIn(event);
},
[draggable, interaction],
);
const handleWorkspacePressOut = useCallback(() => {
setIsPressed(false);
if (draggable) interaction.handlePressOut();
}, [draggable, interaction]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const workspaceRowStyle = getWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...(draggable ? dragAttributes : {})}
{...(draggable ? dragHandleProps?.listeners : {})}
ref={
draggable ? (dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>) : undefined
}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange}
workspace={workspace}
hostBadgeLabel={hostBadge?.label}
serviceSummary={serviceSummary}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handleWorkspacePressIn}
onTouchMove={draggable ? interaction.handleTouchMove : undefined}
onPressOut={handleWorkspacePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
hostBadge={hostBadge}
serviceSummary={serviceSummary}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
<WorkspaceRowTrailingActions
workspace={workspace}
backdrop={backdrop}
trailing={trailing}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
onArchive={onArchive}
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
/>
</SidebarWorkspaceRowContent>
</SidebarWorkspaceContextMenu>
</View>
);
}}
</SidebarWorkspaceRowFrame>
);
}
function WorkspaceRowTrailingActions({
workspace,
backdrop,
trailing,
isHovered,
isTouchPlatform,
isCreating,
showShortcutBadge,
shortcutNumber,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
onArchive,
onMarkAsRead,
onMarkAsUnread,
onCopyBranchName,
onCopyPath,
onRename,
}: {
workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
trailing: SidebarWorkspaceTrailing;
isHovered: boolean;
isTouchPlatform: boolean;
isCreating: boolean;
showShortcutBadge: boolean;
shortcutNumber: number | null;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
onArchive?: () => void;
onMarkAsRead?: () => void;
onMarkAsUnread?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
}) {
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
trailingPresentation,
showKebab: showKebabInSlot,
showArchiveButton,
showScrim,
renderSlot,
reserveSlotWidth,
} = resolveTrailingActionVisibility({
workspace,
trailing,
hasArchiveAction: Boolean(onArchive),
isHovered,
isTouchPlatform,
showShortcut,
});
const kebab = useOpenKebabMenuVisibility(showKebabInSlot);
return (
<>
{isCreating ? (
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
) : null}
{renderSlot ? (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
workspaceKey={workspace.workspaceKey}
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
workspaceKey={workspace.workspaceKey}
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
workspaceLabels={workspace.labels}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
) : null}
</>
);
}
function getWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
}: {
isDragging: boolean;
isPressed: boolean;
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
isHovered && styles.workspaceRowHovered,
selected && styles.sidebarRowSelected,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
export const MemoSidebarWorkspaceRow = memo(SidebarWorkspaceRow);
const styles = StyleSheet.create((theme) => ({
workspaceRowContainer: {
position: "relative",
},
workspaceRow: {
minHeight: density.rowHeight,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "center",
gap: theme.spacing[1],
userSelect: "none",
},
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
workspaceRowDragging: {
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
...theme.shadow.md,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarSelected,
},
workspaceCreatingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
flexShrink: 0,
},
}));
@@ -63,6 +63,7 @@ import {
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import { density, densityIconBox } from "@/styles/density";
import { DensityIcon } from "@/components/ui/density-icon";
@@ -1369,6 +1370,7 @@ export function Combobox({
useEffect(() => {
if (!active && isOpen) handleClose();
}, [active, isOpen, handleClose]);
useDismissibleOverlay(isOpen, handleClose);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
@@ -165,7 +165,9 @@ export function ContextMenuTrigger({
const handleContextMenu = useCallback(
(event: unknown) => {
if (isNative) return;
// A trigger with no menu of its own leaves the browser's menu alone, e.g. copy and paste
// in a field rendered inside it.
if (isNative || !shouldEnableOnThisPlatform) return;
if (typeof event === "object" && event !== null) {
const preventDefault = Reflect.get(event, "preventDefault");
const stopPropagation = Reflect.get(event, "stopPropagation");
@@ -175,7 +177,7 @@ export function ContextMenuTrigger({
onContextMenu?.(event);
openAtEvent(event);
},
[onContextMenu, openAtEvent],
[onContextMenu, openAtEvent, shouldEnableOnThisPlatform],
);
const resolveDynamicStyle = useCallback(
@@ -5,6 +5,7 @@ import {
} from "@gorhom/bottom-sheet";
import React from "react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
import type { ElementRef, ReactNode } from "react";
import { systemBackPress } from "./back-press";
import {
@@ -163,6 +164,7 @@ export function useIsolatedBottomSheetVisibility({
useEffect(() => {
tracker.syncDesired({ visible, isEnabled });
}, [isEnabled, tracker, visible]);
useDismissibleOverlay(visible && isEnabled !== false, onClose);
return {
sheetRef: setSheetRef,
@@ -11,6 +11,7 @@ import {
type MenuPath,
} from "./menu-navigation";
import type { Rect } from "../anchor";
import { useDismissibleOverlay } from "../overlay-dismissal";
/**
* How a menu draws itself once it is open.
@@ -117,6 +118,7 @@ export function useMenuState({
const [isOpen, setIsOpenState] = useControllableOpenState({ open, defaultOpen, onOpenChange });
useDismissKeyboardOnOpen(isOpen);
useDismissibleOverlay(isOpen, () => setOpen(false));
// A menu always reopens on its root page, against a freshly measured anchor. Keeping either
// would reopen the surface three levels deep, or at the coordinates of the last right click.
+12 -2
View File
@@ -1,13 +1,23 @@
import { Modal as NativeModal, type ModalProps } from "react-native";
import { Modal as NativeModal, type ModalProps as NativeModalProps } from "react-native";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
// React Native defaults iPhone modals to portrait even when the app supports rotation.
const orientations: NonNullable<ModalProps["supportedOrientations"]> = [
const orientations: NonNullable<NativeModalProps["supportedOrientations"]> = [
"portrait",
"portrait-upside-down",
"landscape-left",
"landscape-right",
];
type ModalProps = Omit<NativeModalProps, "onRequestClose"> & {
/** Back, Escape, and a composer taking focus all close the modal through this. */
onRequestClose?: () => void;
};
function noClose() {}
export function Modal(props: ModalProps) {
const { visible = true, onRequestClose } = props;
useDismissibleOverlay(visible && onRequestClose !== undefined, onRequestClose ?? noClose);
return <NativeModal supportedOrientations={orientations} {...props} />;
}
@@ -0,0 +1,90 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
} from "react";
interface OverlayDismissal {
register(close: () => void): () => void;
dismissAll(): Promise<void>;
}
interface OpenOverlay {
close: () => void;
/** Set once the overlay is asked to close; every caller waits on the same one. */
closing: { gone: Promise<void>; settle: () => void } | null;
}
function createOverlayDismissal(): OverlayDismissal {
const open = new Set<OpenOverlay>();
function dismiss(overlay: OpenOverlay): Promise<void> {
if (!overlay.closing) {
let settle = () => {};
const gone = new Promise<void>((resolve) => {
settle = resolve;
});
overlay.closing = { gone, settle };
overlay.close();
}
return overlay.closing.gone;
}
return {
register(close) {
const overlay: OpenOverlay = { close, closing: null };
open.add(overlay);
return () => {
open.delete(overlay);
overlay.closing?.settle();
};
},
dismissAll() {
// An overlay is gone in the commit that unregisters it. That commit can also move focus, such
// as a web modal handing it back to the button that opened it.
return Promise.all([...open].map(dismiss)).then(() => undefined);
},
};
}
const OverlayDismissalContext = createContext<OverlayDismissal | null>(null);
/**
* A region that can close the sheets, menus and popovers opened from inside it. The composer is
* one: putting the cursor in it closes whatever it opened over itself. A scope inside another joins
* it, so a host widens the region by opening a scope around the composer and its neighbours.
*/
export function OverlayDismissalScope({ children }: PropsWithChildren): ReactElement {
const enclosing = useContext(OverlayDismissalContext);
const [own] = useState(createOverlayDismissal);
return (
<OverlayDismissalContext.Provider value={enclosing ?? own}>
{children}
</OverlayDismissalContext.Provider>
);
}
/** While `open`, the enclosing scope, if any, can close this overlay through `close`. */
export function useDismissibleOverlay(open: boolean, close: () => void): void {
const scope = useContext(OverlayDismissalContext);
const closeRef = useRef(close);
closeRef.current = close;
useEffect(() => {
if (!scope || !open) return;
return scope.register(() => closeRef.current());
}, [scope, open]);
}
/**
* Closes every overlay open in the enclosing scope, and resolves once they are gone. Outside a
* scope there is nothing to close. A registered `close` must close the overlay.
*/
export function useDismissOverlays(): () => Promise<void> {
const scope = useContext(OverlayDismissalContext);
return useCallback(() => scope?.dismissAll() ?? Promise.resolve(), [scope]);
}
@@ -0,0 +1,30 @@
import { createContext, useCallback, useContext, useState, useSyncExternalStore } from "react";
import { createUserMessageExpansion, type UserMessageExpansion } from "./expansion";
export const UserMessageExpansionContext = createContext<UserMessageExpansion | null>(null);
/** One store per viewed agent; switching agents starts with every message collapsed. */
export function useAgentUserMessageExpansion(agentId: string): UserMessageExpansion {
const [owner, setOwner] = useState(() => ({
agentId,
expansion: createUserMessageExpansion(),
}));
if (owner.agentId === agentId) return owner.expansion;
const next = { agentId, expansion: createUserMessageExpansion() };
setOwner(next);
return next.expansion;
}
/** Subscribes one message to its own expanded state. */
export function useUserMessageExpanded(itemId: string) {
const expansion = useContext(UserMessageExpansionContext);
if (!expansion) throw new Error("MessagePreview requires a UserMessageExpansionContext");
const subscribe = useCallback(
(listener: () => void) => expansion.subscribe(itemId, listener),
[expansion, itemId],
);
const getSnapshot = useCallback(() => expansion.isExpanded(itemId), [expansion, itemId]);
const expanded = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const toggle = useCallback(() => expansion.toggle(itemId), [expansion, itemId]);
return { expanded, toggle };
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { createUserMessageExpansion } from "./expansion";
describe("user message expansion", () => {
it("toggles one message and notifies only that message", () => {
const expansion = createUserMessageExpansion();
const notified: string[] = [];
expansion.subscribe("first", () => notified.push("first"));
expansion.subscribe("second", () => notified.push("second"));
expansion.toggle("first");
expect(expansion.isExpanded("first")).toBe(true);
expect(expansion.isExpanded("second")).toBe(false);
expansion.toggle("first");
expect(expansion.isExpanded("first")).toBe(false);
expect(notified).toEqual(["first", "first"]);
});
it("keeps a message's choice after its subscribers leave, as a remounting row does", () => {
const expansion = createUserMessageExpansion();
const unsubscribe = expansion.subscribe("first", () => undefined);
expansion.toggle("first");
unsubscribe();
const notified: string[] = [];
expansion.subscribe("first", () => notified.push("first"));
expect(expansion.isExpanded("first")).toBe(true);
expect(notified).toEqual([]);
});
});
@@ -0,0 +1,34 @@
type Listener = () => void;
/**
* Which of one agent's long user messages the user expanded past the preview. It lives with the
* timeline, not the row, so a row that remounts (a virtualized row coming back into view, a row
* reassigned to another list container) paints the choice the user made. A toggle notifies only
* that message's subscribers.
*/
export function createUserMessageExpansion() {
const expanded = new Set<string>();
const listeners = new Map<string, Set<Listener>>();
return {
isExpanded: (itemId: string) => expanded.has(itemId),
subscribe(itemId: string, listener: Listener) {
let itemListeners = listeners.get(itemId);
if (!itemListeners) {
itemListeners = new Set();
listeners.set(itemId, itemListeners);
}
itemListeners.add(listener);
return () => {
itemListeners.delete(listener);
if (itemListeners.size === 0) listeners.delete(itemId);
};
},
toggle(itemId: string) {
if (expanded.has(itemId)) expanded.delete(itemId);
else expanded.add(itemId);
for (const listener of listeners.get(itemId) ?? []) listener();
},
};
}
export type UserMessageExpansion = ReturnType<typeof createUserMessageExpansion>;
@@ -0,0 +1,81 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import { MessagePreview } from "./index";
import { createUserMessageExpansion, type UserMessageExpansion } from "./expansion";
import { UserMessageExpansionContext } from "./expansion-context";
// Load translations so the toggle exposes its real accessible name.
void testI18n;
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
container.style.width = "400px";
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n");
const preLine = { whiteSpace: "pre-line" } as const;
const view = () => within(container);
function renderMessage(expansion: UserMessageExpansion) {
act(() =>
root.render(
<UserMessageExpansionContext.Provider value={expansion}>
<MessagePreview itemId="message-1">
<span style={preLine}>{longText}</span>
</MessagePreview>
</UserMessageExpansionContext.Provider>,
),
);
}
async function waitForLayout() {
await act(async () => {
for (let frame = 0; frame < 2; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
});
}
describe("long user message preview", () => {
it("commits the control with the mount, so the first frame is the settled one", async () => {
renderMessage(createUserMessageExpansion());
// Nothing has painted yet: whatever is in the DOM now is the first frame.
expect(view().getByRole("button", { name: "Show more" })).toBeTruthy();
const firstFrameHeight = container.getBoundingClientRect().height;
await waitForLayout();
expect(view().getByRole("button", { name: "Show more" })).toBeTruthy();
expect(container.getBoundingClientRect().height).toBe(firstFrameHeight);
});
it("paints the user's expanded choice when the row remounts", async () => {
const expansion = createUserMessageExpansion();
renderMessage(expansion);
act(() => view().getByRole("button", { name: "Show more" }).click());
await waitForLayout();
const expandedHeight = container.getBoundingClientRect().height;
act(() => root.render(null));
renderMessage(expansion);
expect(view().getByRole("button", { name: "Show less" })).toBeTruthy();
expect(container.getBoundingClientRect().height).toBe(expandedHeight);
});
});
@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, type ReactNode } from "react";
import { useState, useCallback, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
import { View, type LayoutChangeEvent, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
@@ -10,6 +10,7 @@ import { useSessionStore } from "@/stores/session-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { isNative } from "@/constants/platform";
import { useDisclosureHold } from "@/agent-stream/disclosure";
import { useUserMessageExpanded } from "./expansion-context";
const PREVIEW_HEIGHT = 240;
// Show more and show less sit under the text they open and close.
@@ -21,24 +22,35 @@ const previewStyle: ViewStyle = {
overflow: isNative ? "scroll" : "hidden",
};
export function MessagePreview({ children }: { children: ReactNode }) {
/** Caps a long message at the preview height, with a control to read the rest. */
export function MessagePreview({ itemId, children }: { itemId: string; children: ReactNode }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const { expanded, toggle: toggleExpanded } = useUserMessageExpanded(itemId);
const bodyRef = useRef<View>(null);
const [overflowing, setOverflowing] = useState(false);
// A hidden panel lays out at zero height, which says nothing about the message.
const applyBodyHeight = useCallback((height: number) => {
if (height > 0) setOverflowing(height > PREVIEW_HEIGHT);
}, []);
// Measure in the commit that mounts the body: an update from a layout effect paints with it, so
// the first frame already shows the fade and the control. `onLayout` only lands after paint.
useLayoutEffect(() => {
applyBodyHeight(bodyRef.current!.getBoundingClientRect().height);
}, [applyBodyHeight]);
const measure = useCallback(
(event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT),
[],
(event: LayoutChangeEvent) => applyBodyHeight(event.nativeEvent.layout.height),
[applyBodyHeight],
);
const hold = useDisclosureHold();
const toggle = useCallback(
() => hold(() => setExpanded((value) => !value), TRIGGER_BELOW_CONTENT),
[hold],
() => hold(toggleExpanded, TRIGGER_BELOW_CONTENT),
[hold, toggleExpanded],
);
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
return (
<>
<View style={!expanded && previewStyle}>
<View style={styles.body} onLayout={measure}>
<View ref={bodyRef} style={styles.body} onLayout={measure}>
{children}
</View>
{overflowing && !expanded ? (
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { WorkspaceRenameModal } from "@/workspace-rename";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
@@ -1,70 +0,0 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
// The subset of a workspace the rename dialog needs. Narrower than SidebarWorkspaceEntry so the
// command center can build one from the active route selection without a sidebar row.
export interface RenamableWorkspace {
serverId: string;
workspaceId: string;
name: string;
title?: string | null;
}
export interface WorkspaceRenameModalProps {
visible: boolean;
workspace: RenamableWorkspace;
onClose: () => void;
/**
* Prefix for the modal's testIDs. The sidebar callers must keep passing
* `sidebar-workspace-rename-modal-${workspaceKey}` — e2e/browser/sidebar-workspace-rename.spec.ts
* builds its locators from exactly that prefix.
*/
testID?: string;
}
/**
* Owns the setWorkspaceTitle mutation and every rename string, so a caller only tracks its own
* open/closed boolean. Errors surface inline inside AdaptiveRenameModal; the dialog stays open.
*/
export function WorkspaceRenameModal({
visible,
workspace,
onClose,
testID,
}: WorkspaceRenameModalProps) {
const { t } = useTranslation();
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const renameAsync = renameMutation.mutateAsync;
const handleSubmit = useCallback(
async (value: string) => {
await renameAsync(value.trim());
},
[renameAsync],
);
return (
<AdaptiveRenameModal
visible={visible}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
onClose={onClose}
onSubmit={handleSubmit}
testID={testID}
/>
);
}
@@ -1,5 +1,5 @@
import { QueryClient } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
@@ -142,8 +142,25 @@ function runtimeModel(model: string): Agent["runtimeInfo"] {
return { provider: "claude", sessionId: null, model };
}
function composer() {
return createAgentComposer(serverId, agentId, { queryClient });
function composer(deps: { reveal?: () => void } = {}) {
return createAgentComposer(serverId, agentId, { queryClient, reveal: () => {}, ...deps });
}
/** A mounted input that records the focus it takes. */
function mountedInput(on: { revealed: boolean }) {
const focused: unknown[] = [];
return {
focused,
input: {
isLive: () => on.revealed,
setText() {},
insertText() {},
isRevealed: () => on.revealed,
focus: async (options?: unknown) => {
focused.push(options);
},
},
};
}
beforeEach(() => {
@@ -157,6 +174,143 @@ afterEach(() => {
useSessionStore.getState().clearSession(serverId);
});
describe("agent composer focus", () => {
it("only focuses an input that is already on screen", async () => {
openSession(agent());
let reveals = 0;
const model = composer({ reveal: () => (reveals += 1) });
const { input, focused } = mountedInput({ revealed: true });
model.attachInput(input);
await model.focus({ selection: { start: 1, end: 3 } });
expect(focused).toEqual([{ selection: { start: 1, end: 3 } }]);
expect(reveals).toBe(0);
});
it("reveals an agent with no mounted composer and focuses the input that arrives", async () => {
openSession(agent());
let reveals = 0;
const model = composer({ reveal: () => (reveals += 1) });
const focusing = model.focus();
expect(reveals).toBe(1);
const { input, focused } = mountedInput({ revealed: true });
model.attachInput(input);
await focusing;
expect(focused).toEqual([undefined]);
});
it("focuses after the commit that revealed the input has applied a pending prompt", async () => {
openSession(agent());
const model = composer({ reveal() {} });
const focusing = model.focus({ selection: { start: 0, end: 5 } });
let text = "";
const textWhenFocused: string[] = [];
model.attachInput({
...mountedInput({ revealed: true }).input,
focus: async () => {
textWhenFocused.push(text);
},
});
text = "HELLO";
await focusing;
expect(textWhenFocused).toEqual(["HELLO"]);
});
it("waits for a retained input to come on screen before focusing it", async () => {
openSession(agent());
let reveals = 0;
const model = composer({ reveal: () => (reveals += 1) });
const on = { revealed: false };
const { input, focused } = mountedInput(on);
const detach = model.attachInput(input);
const focusing = model.focus();
expect(reveals).toBe(1);
expect(focused).toEqual([]);
// The mounted composer re-attaches when it comes on screen.
on.revealed = true;
detach();
model.attachInput(input);
await focusing;
expect(focused).toEqual([undefined]);
});
it("applies only the latest of two focus requests", async () => {
openSession(agent());
const model = composer();
const first = model.focus({ selection: { start: 0, end: 0 } });
const second = model.focus({ selection: { start: 2, end: 2 } });
const { input, focused } = mountedInput({ revealed: true });
model.attachInput(input);
await Promise.all([first, second]);
expect(focused).toEqual([{ selection: { start: 2, end: 2 } }]);
});
it("lets a call made while an arriving input is being focused decide the selection", async () => {
openSession(agent());
const model = composer();
const first = model.focus({ selection: { start: 0, end: 1 } });
const { input, focused } = mountedInput({ revealed: true });
model.attachInput(input);
const second = model.focus({ selection: { start: 2, end: 4 } });
await Promise.all([first, second]);
expect(focused.at(-1)).toEqual({ selection: { start: 2, end: 4 } });
});
it("settles a replaced focus request with the request that replaced it", async () => {
vi.useFakeTimers();
try {
openSession(agent());
const model = composer();
const settled: string[] = [];
const record = (name: string, request: Promise<void>) =>
request.then(
() => settled.push(`${name} focused`),
() => settled.push(`${name} failed`),
);
const first = record("first", model.focus());
const second = record("second", model.focus());
await vi.advanceTimersByTimeAsync(60_000);
await Promise.all([first, second]);
expect(settled).toEqual(["second failed", "first failed"]);
} finally {
vi.useRealTimers();
}
});
it("rejects when the composer never comes on screen, and a later mount does not take focus", async () => {
vi.useFakeTimers();
try {
openSession(agent());
const model = composer();
const focusing = model.focus();
const failed = expect(focusing).rejects.toThrow("did not come on screen");
await vi.advanceTimersByTimeAsync(60_000);
await failed;
const { input, focused } = mountedInput({ revealed: true });
model.attachInput(input);
expect(focused).toEqual([]);
} finally {
vi.useRealTimers();
}
});
it("rejects for an archived agent without navigating to it", async () => {
openSession(agent({ archivedAt: new Date() }));
let reveals = 0;
const model = composer({ reveal: () => (reveals += 1) });
await expect(model.focus()).rejects.toThrow("archived");
expect(reveals).toBe(0);
});
});
describe("agent composer", () => {
it("reads without notifying anyone, so a read during a render updates no other component", () => {
const running = agent();
@@ -169,8 +323,8 @@ describe("agent composer", () => {
[other.id, other],
]),
);
const watched = createAgentComposer(serverId, running.id, { queryClient });
const reader = createAgentComposer(serverId, other.id, { queryClient });
const watched = createAgentComposer(serverId, running.id, { queryClient, reveal: () => {} });
const reader = createAgentComposer(serverId, other.id, { queryClient, reveal: () => {} });
let notifications = 0;
const stop = watched.subscribe(() => {
notifications += 1;
@@ -201,6 +355,8 @@ describe("agent composer", () => {
isLive: () => true,
setText: (text) => mounted.push(`set:${text}`),
insertText: (text) => mounted.push(`insert:${text}`),
isRevealed: () => false,
focus: async () => {},
});
model.insertText("at the cursor");
model.setPrompt("replaced");
@@ -219,6 +375,8 @@ describe("agent composer", () => {
isLive: () => false,
setText: (text) => mounted.push(`set:${text}`),
insertText: (text) => mounted.push(`insert:${text}`),
isRevealed: () => false,
focus: async () => {},
});
model.insertText("while away");
expect(mounted).toEqual([]);
@@ -396,7 +554,7 @@ describe("agent composer", () => {
it("is shared while its agent is in the session, across closing and reopening its tab", () => {
openSession(agent());
const shared = agentComposer(serverId, agentId);
const detach = shared.attachInput({ isLive: () => true, setText() {}, insertText() {} });
const detach = shared.attachInput(mountedInput({ revealed: true }).input);
detach();
expect(agentComposer(serverId, agentId)).toBe(shared);
const stop = shared.subscribe(() => {});
@@ -407,7 +565,7 @@ describe("agent composer", () => {
it("is released when the last holder lets go of a composer whose agent was removed meanwhile", () => {
openSession(agent());
const mounted = agentComposer(serverId, agentId);
const detach = mounted.attachInput({ isLive: () => true, setText() {}, insertText() {} });
const detach = mounted.attachInput(mountedInput({ revealed: true }).input);
useSessionStore.getState().setAgents(serverId, new Map());
// Held, so the session change did not release it, and nothing changes in the session after.
expect(agentComposer(serverId, agentId)).toBe(mounted);
@@ -8,13 +8,14 @@ import {
resolveAgentModelSelection,
resolveProviderSnapshotEntry,
} from "@/composer/agent-controls/utils";
import type { ComposerModel, ComposerState } from "@/composer/model";
import type { ComposerFocusOptions, ComposerModel, ComposerState } from "@/composer/model";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { toDraftInputIfReady, type DraftRecord } from "@/stores/draft-store/state";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { UserComposerAttachment } from "@/attachments/types";
import { navigateToAgent } from "@/utils/navigate-to-agent";
/** The mounted composer's input. While attached, prompt commands go through it. */
export interface MountedComposerInput {
@@ -23,6 +24,9 @@ export interface MountedComposerInput {
setText(text: string): void;
/** Inserts at the cursor, replacing the selection. */
insertText(text: string): void;
/** On screen and able to take the keyboard: `focus` needs nothing revealed first. */
isRevealed(): boolean;
focus(options?: ComposerFocusOptions): Promise<void>;
}
/**
@@ -41,6 +45,20 @@ interface AgentComposerDeps {
cache?: ProviderSnapshotCache;
/** The last watcher or mounted composer let go, so the composer may be releasable. */
onUnheld?: () => void;
/** Brings the agent's composer on screen, wherever the user is. */
reveal: () => void;
}
// Long enough for a cold workspace to load its chat over a relay.
const REVEAL_TIMEOUT_MS = 15_000;
/** A focus waiting for the revealed input to come on screen. */
interface PendingFocus {
/** Which `focus()` call this is; a later call decides where the cursor goes. */
call: number;
options: ComposerFocusOptions | undefined;
settle: { resolve: () => void; reject: (error: Error) => void };
timeout: ReturnType<typeof setTimeout>;
}
type Catalog = { status: "loading" } | { status: "ready"; entries: ProviderSnapshotEntry[] | null };
@@ -66,6 +84,9 @@ export function createAgentComposer(
// One pair of store subscriptions serves every listener, from the first to the last.
let stopWatchingStores: (() => void) | null = null;
let mounted: MountedComposerInput | null = null;
let pendingFocus: PendingFocus | null = null;
let focusCalls = 0;
let latestFocus: Promise<void> = Promise.resolve();
let last: {
agent: Agent | null;
draft: DraftRecord | undefined;
@@ -209,6 +230,25 @@ export function createAgentComposer(
return last.state;
}
function takePendingFocus(): PendingFocus | null {
const pending = pendingFocus;
pendingFocus = null;
if (pending) clearTimeout(pending.timeout);
return pending;
}
function focusIfRevealed(input: MountedComposerInput) {
if (!pendingFocus || !input.isRevealed()) return;
const { call, options, settle } = takePendingFocus()!;
// Attaching runs in a commit's effects, and a prompt set alongside this request can be applied
// by a later effect in that commit. A selection has to see that text.
queueMicrotask(() => {
// A call made since then, such as from a mounting item, decides; this one ends with it.
const outcome = call === focusCalls ? input.focus(options) : latestFocus;
outcome.then(settle.resolve, settle.reject);
});
}
function unheld() {
if (listeners.size === 0 && mounted === null) deps.onUnheld?.();
}
@@ -237,6 +277,7 @@ export function createAgentComposer(
},
attachInput(input) {
mounted = input;
focusIfRevealed(input);
return () => {
if (mounted === input) mounted = null;
unheld();
@@ -253,6 +294,34 @@ export function createAgentComposer(
useDraftStore.getState().replaceDraftText({ draftKey, text: current + text });
}
},
focus(options) {
const agent = readAgent();
if (!agent || agent.archivedAt != null) {
return Promise.reject(new Error("The agent is gone or archived."));
}
const call = ++focusCalls;
if (mounted?.isRevealed()) {
latestFocus = mounted.focus(options);
return latestFocus;
}
const replaced = takePendingFocus();
const request = new Promise<void>((resolve, reject) => {
pendingFocus = {
call,
options,
settle: { resolve, reject },
timeout: setTimeout(() => {
takePendingFocus();
reject(new Error("The composer did not come on screen."));
}, REVEAL_TIMEOUT_MS),
};
deps.reveal();
});
// The latest request decides where the cursor goes, and the one it replaced ends with it.
if (replaced) request.then(replaced.settle.resolve, replaced.settle.reject);
latestFocus = request;
return request;
},
changeAttachments(updater) {
useDraftStore.getState().updateDraftInput({
draftKey,
@@ -304,6 +373,7 @@ export function agentComposer(serverId: string, agentId: string): AgentComposer
composer = createAgentComposer(serverId, agentId, {
queryClient: singletonQueryClient,
onUnheld: releaseReleasableComposers,
reveal: () => navigateToAgent({ serverId, agentId, asTab: true }),
});
composers.set(key, composer);
}
@@ -1181,6 +1181,8 @@ export const AgentControls = memo(function AgentControls({
isLive: composerInput.isLive,
setText: composerInput.setText,
insertText: composerInput.insertText,
isRevealed: composerInput.isRevealed,
focus: composerInput.focus,
})
: undefined,
[composer, composerInput],
@@ -49,6 +49,12 @@ export function composerModelFromHalves(input: {
assertUnlocked(composerInput.isLocked);
composerInput.insertText(text);
},
// Only an item inside this composer can hold it, so the user was here when they asked. Paseo
// has no way to name a draft from outside it and does not pull the user back to one.
async focus(options) {
if (!composerInput.isRevealed()) throw new Error("The composer is not on screen.");
await composerInput.focus(options);
},
changeAttachments(updater) {
assertUnlocked(composerInput.isLocked);
composerInput.changeAttachments(updater);
+85 -5
View File
@@ -42,7 +42,7 @@ import {
type ToolbarGlyph,
toolbarSlot,
} from "@/components/ui/control-geometry";
import { usePanelStore } from "@/stores/panel-store";
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
import { useShallow } from "zustand/shallow";
import {
@@ -137,6 +137,9 @@ import { submitAgentInput } from "@/composer/submit";
import { ComposerRecall } from "@/composer/recall";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { usePaneHost } from "@/panels/pane-context";
import { OverlayDismissalScope, useDismissOverlays } from "@/components/ui/overlay-dismissal";
import type { PaneHost } from "@/panels/panel-manifest";
import { useAppSettings } from "@/hooks/use-settings";
import { RenderProfile } from "@/utils/render-profiler";
import { AfterPaintPublication } from "@/composer/after-paint-publication";
@@ -152,6 +155,8 @@ import type {
import type { SelectedFile } from "@/attachments/selected-file";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { ComposerInputProvider, type ComposerInput } from "@/composer/session";
import type { ComposerFocusOptions } from "@/composer/model";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { insertAtSelection } from "@/composer/insert-text";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
@@ -1212,6 +1217,24 @@ function ComposerVoiceModeButton({
);
}
// A plugin modal can refuse to close; the request then tries the cursor anyway and settles.
const OVERLAY_CLOSE_TIMEOUT_MS = 1_000;
function closedWithin(closed: Promise<void>, timeoutMs: number): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined;
const expired = new Promise<void>((resolve) => {
timeout = setTimeout(resolve, timeoutMs);
});
return Promise.race([closed, expired]).finally(() => clearTimeout(timeout));
}
/** Only the agent list can cover a screen outside a workspace: the Explorer lives in the workspace. */
function isMobilePanelCoveringHost(target: MobilePanelView, host: PaneHost | null): boolean {
if (target === "agent-list") return true;
if (host === null) return false;
return target !== (host === "explorer" ? "file-explorer" : "agent");
}
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
// A compact overlay panel (agent list, explorer) covers the pane without taking its
// focus. The composer gives up input from the moment the user asks for the panel,
@@ -1219,16 +1242,21 @@ export function Composer({ isPaneFocused, ...props }: ComposerProps) {
// Pane focus stays put, so the keyboard the panel dismisses is a blur in the
// user's own pane: closing the panel does not bring focus back.
const isCompact = useIsCompactFormFactor();
const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent");
const paneHost = usePaneHost();
const overlayPanelRequested = usePanelStore((state) =>
isMobilePanelCoveringHost(state.mobilePanel.target, paneHost),
);
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
return (
<ComposerKeyboardScopeProvider
isPaneFocused={isPaneFocused}
isActiveComposer={isPaneFocused && !coveredByOverlayPanel}
>
<RenderProfile id="ComposerContent">
<ComposerContent {...props} />
</RenderProfile>
<OverlayDismissalScope>
<RenderProfile id="ComposerContent">
<ComposerContent {...props} />
</RenderProfile>
</OverlayDismissalScope>
</ComposerKeyboardScopeProvider>
);
}
@@ -1431,6 +1459,52 @@ function ComposerContentImpl({
[onChangeText, textSource],
);
const isInputLive = useIsInputLive(messageInputRef);
const isPanelActive = useRetainedPanelActive();
const { isActiveComposer } = useComposerKeyboardScope();
const inputRevealed = isPanelActive && isActiveComposer;
const isActiveComposerRef = useRef(isActiveComposer);
isActiveComposerRef.current = isActiveComposer;
const isInputRevealed = useCallback(
() => isInputLive() && isActiveComposerRef.current,
[isInputLive],
);
const dismissOverlays = useDismissOverlays();
const latestFocusRef = useRef<Promise<void> | null>(null);
const takeInputFocus = useCallback(
(options?: ComposerFocusOptions) => {
const isFocused = () => messageInputRef.current?.isFocused() ?? false;
const place = () => {
messageInputRef.current?.focus();
if (options?.selection) messageInputRef.current?.setSelection(options.selection);
};
// The sheets, menus and modals this composer opened would otherwise cover the cursor, or take
// focus back as they close.
const request: Promise<void> = closedWithin(dismissOverlays(), OVERLAY_CLOSE_TIMEOUT_MS).then(
() => {
// A newer request decides where the cursor goes, and this one ends with it.
const latest = latestFocusRef.current;
if (latest && latest !== request) return latest;
if (isNative) {
place();
// A native input refuses focus without saying so, such as while dictation holds it.
if (!isFocused()) throw new Error("The composer input could not take focus.");
return;
}
return new Promise<void>((resolve, reject) => {
focusWithRetries({
focus: place,
isFocused,
onSuccess: resolve,
onTimeout: () => reject(new Error("The composer input could not take focus.")),
});
});
},
);
latestFocusRef.current = request;
return request;
},
[dismissOverlays],
);
const isComposerLockedRef = useRef(isComposerLocked);
isComposerLockedRef.current = isComposerLocked;
const composerInput = useMemo<ComposerInput>(
@@ -1438,6 +1512,9 @@ function ComposerContentImpl({
locked: isComposerLocked,
isLocked: () => isComposerLockedRef.current,
isLive: isInputLive,
revealed: inputRevealed,
isRevealed: isInputRevealed,
focus: takeInputFocus,
textSource,
setText: replaceUserInput,
insertText: insertUserInput,
@@ -1446,11 +1523,14 @@ function ComposerContentImpl({
}),
[
attachments,
inputRevealed,
insertUserInput,
isComposerLocked,
isInputLive,
isInputRevealed,
replaceUserInput,
setSelectedAttachments,
takeInputFocus,
textSource,
],
);
+10
View File
@@ -225,9 +225,12 @@ export interface MessageInputProps {
export interface MessageInputRef {
focus: () => void;
blur: () => void;
isFocused: () => boolean;
getText: () => string;
getInputSnapshot: () => ComposerInputSnapshot;
replaceText: (text: string, selection?: { start: number; end: number }) => void;
/** Clamped to the text the input holds. */
setSelection: (selection: { start: number; end: number }) => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
@@ -1486,6 +1489,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
focus: () => {
textInputRef.current?.focus();
},
isFocused: () => textInputRef.current?.isFocused() ?? false,
blur: () => {
textInputRef.current?.blur();
},
@@ -1493,6 +1497,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
getInputSnapshot: () =>
getComposerInputSnapshot(textInputRef.current, valueRef.current, selectionRef.current),
replaceText,
setSelection: (selection) => {
const text = textInputRef.current?.getText() ?? valueRef.current;
const clamped = resolveRetainedSelection(selection, text.length);
selectionRef.current = clamped;
textInputRef.current?.setSelection(clamped);
},
runKeyboardAction: (action) =>
runMessageInputKeyboardAction(action, {
focusInput: () => textInputRef.current?.focus(),
+8
View File
@@ -17,6 +17,12 @@ export interface ComposerState {
metadata: Record<string, JsonValue>;
}
/** Where the cursor goes when a composer takes focus. */
export interface ComposerFocusOptions {
/** Offsets into the prompt, clamped to its length. Omit to keep the cursor where it is. */
selection?: { start: number; end: number };
}
/**
* A composer as plugins use it: its state, a way to hear about changes, and the commands that
* change it. The commands enforce the rules the built-in controls follow (a draft being created
@@ -29,6 +35,8 @@ export interface ComposerModel {
setPrompt(text: string): void;
/** Inserts at the cursor, replacing the selection. */
insertText(text: string): void;
/** Puts the cursor in the input. Resolves once it is there; rejects when it cannot get there. */
focus(options?: ComposerFocusOptions): Promise<void>;
changeAttachments(updater: (current: UserComposerAttachment[]) => UserComposerAttachment[]): void;
setProvider(provider: string): Promise<void>;
setModel(modelId: string): Promise<void>;
+9
View File
@@ -1,6 +1,7 @@
import { createContext, useContext, type ReactNode } from "react";
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { ComposerFocusOptions } from "@/composer/model";
import type { ComposerTextSource } from "@/composer/text-source";
/**
@@ -17,6 +18,14 @@ export interface ComposerInput {
* cannot take the cursor-aware commands below.
*/
isLive: () => boolean;
/**
* The input is showing and nothing stands between it and the keyboard: its pane is focused and
* no compact overlay panel covers it. A new value of `revealed` is a new `ComposerInput`, which
* is how a request waiting for the input to come on screen hears about it.
*/
revealed: boolean;
isRevealed: () => boolean;
focus: (options?: ComposerFocusOptions) => Promise<void>;
textSource: ComposerTextSource;
setText: (text: string) => void;
/** Inserts at the cursor, replacing the selection. */
+2
View File
@@ -1177,6 +1177,8 @@ export const ar: TranslationResources = {
title: "ما الجديد",
installed: "مثبّت",
showMore: "عرض المزيد",
showAllChanges: "عرض كل التغييرات",
hideAllChanges: "إخفاء كل التغييرات",
openWebsite: "سجل التغييرات الكامل",
error: {
title: "تعذّر تحميل سجل التغييرات",
+2
View File
@@ -1185,6 +1185,8 @@ export const en = {
title: "What's new",
installed: "Installed",
showMore: "Show more",
showAllChanges: "Show all changes",
hideAllChanges: "Hide all changes",
openWebsite: "Full changelog",
error: {
title: "Unable to load the changelog",
+2
View File
@@ -1213,6 +1213,8 @@ export const es: TranslationResources = {
title: "Novedades",
installed: "Instalada",
showMore: "Mostrar más",
showAllChanges: "Mostrar todos los cambios",
hideAllChanges: "Ocultar todos los cambios",
openWebsite: "Registro de cambios completo",
error: {
title: "No se pudo cargar el registro de cambios",
+2
View File
@@ -1206,6 +1206,8 @@ export const fr: TranslationResources = {
title: "Nouveautés",
installed: "Installée",
showMore: "Afficher plus",
showAllChanges: "Afficher toutes les modifications",
hideAllChanges: "Masquer toutes les modifications",
openWebsite: "Journal des modifications complet",
error: {
title: "Impossible de charger le journal des modifications",
+2
View File
@@ -1190,6 +1190,8 @@ export const ja: TranslationResources = {
title: "新着情報",
installed: "インストール済み",
showMore: "もっと見る",
showAllChanges: "すべての変更を表示",
hideAllChanges: "すべての変更を非表示",
openWebsite: "変更履歴をすべて表示",
error: {
title: "変更履歴を読み込めません",
+2
View File
@@ -1184,6 +1184,8 @@ export const ko: TranslationResources = {
title: "새로운 소식",
installed: "설치됨",
showMore: "더 보기",
showAllChanges: "모든 변경 사항 보기",
hideAllChanges: "모든 변경 사항 숨기기",
openWebsite: "전체 변경 내역",
error: {
title: "변경 내역을 불러오지 못했습니다",
+2
View File
@@ -1203,6 +1203,8 @@ export const ptBR: TranslationResources = {
title: "Novidades",
installed: "Instalada",
showMore: "Mostrar mais",
showAllChanges: "Mostrar todas as mudanças",
hideAllChanges: "Ocultar todas as mudanças",
openWebsite: "Changelog completo",
error: {
title: "Não foi possível carregar o changelog",
+2
View File
@@ -1194,6 +1194,8 @@ export const ru: TranslationResources = {
title: "Что нового",
installed: "Установлена",
showMore: "Показать ещё",
showAllChanges: "Показать все изменения",
hideAllChanges: "Скрыть все изменения",
openWebsite: "Полный список изменений",
error: {
title: "Не удалось загрузить список изменений",
+2
View File
@@ -1169,6 +1169,8 @@ export const zhCN: TranslationResources = {
title: "新功能",
installed: "已安装",
showMore: "显示更多",
showAllChanges: "显示全部变更",
hideAllChanges: "收起全部变更",
openWebsite: "完整更新日志",
error: {
title: "无法加载更新日志",
+8 -3
View File
@@ -81,6 +81,7 @@ import {
import { WorkspaceDraftComposer } from "@/composer/draft/workspace-tab";
import { type DraftConversation, useDraftTimeline } from "@/composer/draft/timeline";
import { AgentTracks } from "@/panels/agent-tracks";
import { OverlayDismissalScope } from "@/components/ui/overlay-dismissal";
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
@@ -1317,10 +1318,14 @@ function ChatSurface({
disabled: boolean;
fullscreen: boolean;
}) {
// The tracks above the composer open their pages from outside it, so focusing the composer
// closes everything the chat opened.
return (
<FileDropZone style={styles.container} disabled={disabled}>
<ComposerDock fullscreen={fullscreen}>{children}</ComposerDock>
</FileDropZone>
<OverlayDismissalScope>
<FileDropZone style={styles.container} disabled={disabled}>
<ComposerDock fullscreen={fullscreen}>{children}</ComposerDock>
</FileDropZone>
</OverlayDismissalScope>
);
}
+5
View File
@@ -74,6 +74,11 @@ export function usePaneContext(): PaneContextValue {
return value;
}
/** The host of the pane around this component, or null on a screen with no panes. */
export function usePaneHost(): PaneHost | null {
return useContext(PaneContext)?.host ?? null;
}
export function usePaneFocus(): PaneFocusContextValue {
const value = useContext(PaneFocusContext);
invariant(value, "PaneFocusContext is required");
@@ -7,6 +7,10 @@ import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import { composerModelFromHalves } from "@/composer/halves-model";
import type { ComposerAgent, ComposerInput } from "@/composer/session";
import {
setCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import type { InstalledPlugin } from "../types";
import { toPluginComposer } from "./model";
@@ -94,10 +98,16 @@ function fakeAgent(context: ComposerAgent["context"], locked = false) {
function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
const state = { text: "", attachments: initial };
const view = { revealed: true, focused: [] as unknown[] };
const input: ComposerInput = {
locked,
isLocked: () => locked,
isLive: () => true,
revealed: true,
isRevealed: () => view.revealed,
focus: async (options) => {
view.focused.push(options);
},
textSource: { getSnapshot: () => state.text, subscribe: () => () => {} },
setText: (text) => {
state.text = text;
@@ -112,7 +122,7 @@ function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
state.attachments = updater(state.attachments);
},
};
return { input, state };
return { input, state, view };
}
function compose(agent: ComposerAgent, input: ComposerInput) {
@@ -221,6 +231,47 @@ describe("toPluginComposer", () => {
await expect(composer.setOptions({ effort: "low" })).rejects.toThrow("The composer is locked");
});
it("focuses the input of the draft composer on screen, with the selection asked for", async () => {
const { agent } = fakeAgent("draft");
const { input, view } = fakeInput();
const composer = compose(agent, input);
await composer.focus({ selection: { start: 0, end: 4 } });
expect(view.focused).toEqual([{ selection: { start: 0, end: 4 } }]);
});
it("rejects focus on a draft composer that is not on screen, leaving the user where they are", async () => {
const { agent } = fakeAgent("draft");
const { input, view } = fakeInput();
view.revealed = false;
const composer = compose(agent, input);
await expect(composer.focus()).rejects.toThrow("not on screen");
expect(view.focused).toEqual([]);
});
it("focuses while the composer is locked, since the cursor changes nothing it holds", async () => {
const { agent } = fakeAgent("new-workspace");
const { input, view } = fakeInput([], true);
const composer = compose(agent, input);
await composer.focus();
expect(view.focused).toEqual([undefined]);
});
it("keeps the Command Center from taking focus back when it closes", async () => {
const { agent } = fakeAgent("draft");
const { input } = fakeInput();
const composer = compose(agent, input);
setCommandCenterFocusRestoreElement({} as HTMLElement);
await composer.focus();
expect(takeCommandCenterFocusRestoreElement()).toBeNull();
});
it("hands out options and metadata a plugin cannot change", () => {
const { agent } = fakeAgent("draft");
agent.metadata = { nested: { list: [1] } };
@@ -5,6 +5,7 @@ import type { ComposerModel, ComposerState } from "@/composer/model";
import type { InstalledPlugin } from "../types";
import { createPluginResourceAttachment } from "../attachments/model";
import { cloneAndFreeze, ownCopy } from "../freeze";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
function isPluginAttachment(
attachment: UserComposerAttachment,
@@ -49,6 +50,11 @@ export function toPluginComposer(input: {
},
setPrompt: (text) => model.setPrompt(text),
insertText: (text) => model.insertText(text),
focus(options) {
// The Command Center, which may have run this command, gives focus back when it closes.
clearCommandCenterFocusRestoreElement();
return model.focus(options);
},
addAttachment({ sourceId, item }) {
const source = plugin.attachmentSources.find((candidate) => candidate.id === sourceId);
if (!source) throw new Error(`Unknown attachment source: ${sourceId}`);
@@ -8,6 +8,7 @@ import { encodeImages } from "@/utils/encode-images";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useIsFocused } from "expo-router/react-navigation";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -909,6 +910,7 @@ export function NewWorkspaceScreen({
const hostPickerAnchorRef = useRef<View | null>(null);
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
const isScreenFocused = useIsFocused();
// Launch target: what the composer submits to (chat agent, or a terminal
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
@@ -1544,7 +1546,7 @@ export function NewWorkspaceScreen({
submitLabel={terminalSubmitLabel}
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
isPaneFocused={isScreenFocused}
onSubmitMessage={handleSubmitTerminalLaunch}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
@@ -1568,7 +1570,7 @@ export function NewWorkspaceScreen({
editingSession={composerEditing.chat}
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
isPaneFocused={isScreenFocused}
onSubmitMessage={handleSubmitNewWorkspace}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.create")}
@@ -38,6 +38,7 @@ interface ExplorerSidebarDockProps extends Pick<
normalizedServerId: string;
normalizedWorkspaceId: string;
isWorkspaceFocused: boolean;
paneFocusSuspended: boolean;
closingTabIds: Set<string>;
onSelectTab: (paneId: string, tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
@@ -56,6 +57,7 @@ export function ExplorerSidebarDock({
normalizedServerId,
normalizedWorkspaceId,
isWorkspaceFocused,
paneFocusSuspended,
closingTabIds,
onSelectTab,
onCloseTab,
@@ -167,7 +169,7 @@ export function ExplorerSidebarDock({
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused
isPaneFocused={!paneFocusSuspended}
buildPaneContentModel={buildPaneContentModel}
/>
</View>
@@ -3726,6 +3726,7 @@ function WorkspaceScreenContent({
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
paneFocusSuspended={paneFocusSuspended}
closingTabIds={closingTabIds}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
@@ -3767,6 +3768,7 @@ function WorkspaceScreenContent({
normalizedServerId,
normalizedWorkspaceId,
isRouteFocused,
paneFocusSuspended,
isExplorerSidebarShowing,
closingTabIds,
hoveredCloseTabKey,
@@ -145,6 +145,22 @@ describe("workspace navigation", () => {
]);
});
it("carries a request for a tab through a deferred open", () => {
const { deps, deferredTabs } = createFakeDeps({ getSessionWorkspaces: () => new Map() });
navigateToWorkspace(
{
serverId: "server-1",
workspaceId: "workspace-a",
target: { kind: "agent", agentId: "agent-1" },
asTab: true,
},
deps,
);
expect(deferredTabs[0]).toMatchObject({ asTab: true });
});
it("defers an agent tab until a missing workspace is recovered", () => {
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map(),
@@ -25,6 +25,7 @@ export interface NavigateToWorkspaceInput {
target?: WorkspaceTabTarget;
pin?: boolean;
placement?: WorkspaceTabPlacement;
asTab?: boolean;
}
export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
@@ -4569,6 +4569,24 @@ describe("compact workspace sheet", () => {
);
}
it("opens a subagent as a tab when a deferred request asks for one", () => {
const store = createCompactStore();
seedAgents([
{ id: "parent", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "parent", workspaceId: "sheet-ws" },
]);
store.getState().deferOpenTab({
workspaceKey,
target: { kind: "agent", agentId: "child" },
intent: "reveal",
asTab: true,
});
store.getState().consumePendingOpen(workspaceKey);
expect(topSheet(store)).toBeUndefined();
expect(tabKinds(store)).toContain("agent");
});
it("reveals a file in the sheet and leaves the tab strip unchanged", () => {
const store = createCompactStore();
const before = tabKinds(store);
@@ -105,6 +105,8 @@ export interface OpenWorkspaceTabInput {
/** Keeps an explicitly opened agent visible even when it is archived. */
pin?: boolean;
placement?: WorkspaceTabPlacement;
/** Opens a tab where a compact layout would otherwise use a sheet. */
asTab?: boolean;
insertionPosition?: WorkspaceTabInsertionPosition;
parentTabId?: string;
state?: JsonValue;
@@ -1108,7 +1110,7 @@ export function createWorkspaceLayoutStore(
input.parentTabId,
);
const sheetPlacement =
input.placement?.mode === "pane"
input.placement?.mode === "pane" || input.asTab
? null
: resolveSheetPlacement(
get(),
@@ -40,6 +40,14 @@ function createFakeNavigators(target: AgentNavTarget): {
}
describe("resolveNavigateToAgent", () => {
it("carries a request for a tab to the workspace navigation", () => {
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: WORKSPACE_ID });
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, asTab: true }, deps);
expect(tabNavigations[0]).toMatchObject({ asTab: true });
});
it("opens the workspace tab carried by the agent's workspaceId", () => {
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
agentWorkspaceId: WORKSPACE_ID,
@@ -9,6 +9,8 @@ export interface NavigateToAgentInput {
// (cold deep-links). Otherwise the workspace is read from the store.
workspaceId?: string | null;
pin?: boolean;
/** Opens the agent with its composer, where a compact layout would show a sheet without one. */
asTab?: boolean;
}
export interface AgentNavTarget {
@@ -41,5 +43,6 @@ export function resolveNavigateToAgent(
workspaceId,
target: { kind: "agent", agentId: input.agentId },
pin: input.pin,
asTab: input.asTab,
});
}
@@ -8,6 +8,7 @@ export interface PrepareWorkspaceTabInput {
target: WorkspaceTabTarget;
pin?: boolean;
placement?: WorkspaceTabPlacement;
asTab?: boolean;
}
export interface PrepareWorkspaceTabDeps {
@@ -17,6 +18,7 @@ export interface PrepareWorkspaceTabDeps {
intent: "reveal";
pin?: boolean;
placement?: WorkspaceTabPlacement;
asTab?: boolean;
}) => string | null;
}
@@ -44,5 +46,6 @@ export function prepareWorkspaceTab(
intent: "reveal",
pin: input.pin === true && target.kind === "agent",
placement: input.placement,
asTab: input.asTab,
});
}
@@ -0,0 +1,6 @@
export { WorkspaceRenameModal } from "./internal/modal";
export {
useSidebarWorkspaceRename,
type SidebarRenamableWorkspace,
type SidebarWorkspaceRename,
} from "./internal/use-sidebar-workspace-rename";
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef } from "react";
import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input";
const ThemedTextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
export interface InlineTitleFieldProps {
initialValue: string;
placeholder: string;
/** Holds the submitted text on screen while the daemon accepts it. */
readOnly: boolean;
onCommit: (text: string) => void;
onCancel: () => void;
testID: string;
}
/**
* A single-line input that stands in for the row's title text. It reports exactly one outcome:
* Enter and blur commit, Escape cancels. Enter blurs the input too, so without the settled latch
* one Enter would commit twice.
*
* Its box is the slot it is rendered in (100% of the title line), and the outline that marks it
* as editable paints outside that box, so swapping it in for the title never moves the row.
*/
export function InlineTitleField({
initialValue,
placeholder,
readOnly,
onCommit,
onCancel,
testID,
}: InlineTitleFieldProps) {
const inputRef = useRef<EditingTextInputHandle>(null);
const settledRef = useRef(false);
const focusedRef = useRef(false);
// Rename starts from a menu item. The menu restores focus to its trigger as it closes, so
// focusing in the same commit would lose to it and the blur would commit an unchanged title.
useEffect(() => {
const frame = requestAnimationFrame(() => {
const input = inputRef.current;
if (!input) return;
input.focus();
input.setSelection({ start: 0, end: initialValue.length });
});
return () => cancelAnimationFrame(frame);
}, [initialValue]);
const settle = useCallback((outcome: () => void) => {
if (settledRef.current) return;
settledRef.current = true;
outcome();
}, []);
const commit = useCallback(() => {
settle(() => onCommit(inputRef.current?.getText() ?? initialValue));
}, [initialValue, onCommit, settle]);
const handleFocus = useCallback(() => {
focusedRef.current = true;
}, []);
const handleBlur = useCallback(() => {
// A blur before the first focus is the menu handing focus back, not the user leaving.
if (!focusedRef.current) return;
commit();
}, [commit]);
const handleKeyPress = useCallback(
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
if (event.nativeEvent.key !== "Escape") return;
settle(onCancel);
},
[onCancel, settle],
);
return (
<ThemedTextInput
ref={inputRef}
initialValue={initialValue}
placeholder={placeholder}
editable={!readOnly}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
selectTextOnFocus={false}
onFocus={handleFocus}
onBlur={handleBlur}
onSubmitEditing={commit}
onKeyPress={handleKeyPress}
style={styles.input}
testID={testID}
/>
);
}
const styles = StyleSheet.create((theme) => ({
// Typography matches the sidebar title it replaces; the box comes from the slot.
input: {
width: "100%",
height: "100%",
margin: 0,
padding: 0,
borderWidth: 0,
backgroundColor: "transparent",
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: "400",
includeFontPadding: false,
borderRadius: theme.borderRadius.sm,
outlineStyle: "solid",
outlineWidth: 1,
outlineOffset: 2,
outlineColor: theme.colors.accent,
},
}));
@@ -0,0 +1,53 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import {
type RenamableWorkspace,
useSetWorkspaceTitle,
workspaceRenameInitialValue,
} from "./set-workspace-title";
export interface WorkspaceRenameModalProps {
visible: boolean;
workspace: RenamableWorkspace;
onClose: () => void;
/**
* Prefix for the modal's testIDs. Sidebar rows use
* `sidebar-workspace-rename-modal-${workspaceKey}` — the browser and native-wide e2e flows build
* their locators from exactly that prefix.
*/
testID?: string;
}
/**
* Owns every rename string, so a caller only tracks its own open/closed boolean. Errors surface
* inline inside AdaptiveRenameModal; the dialog stays open.
*/
export function WorkspaceRenameModal({
visible,
workspace,
onClose,
testID,
}: WorkspaceRenameModalProps) {
const { t } = useTranslation();
const setWorkspaceTitle = useSetWorkspaceTitle(workspace);
const submit = useCallback(
async (title: string) => {
await setWorkspaceTitle(title);
},
[setWorkspaceTitle],
);
return (
<AdaptiveRenameModal
visible={visible}
title={t("sidebar.workspace.rename.title")}
initialValue={workspaceRenameInitialValue(workspace)}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
onClose={onClose}
onSubmit={submit}
testID={testID}
/>
);
}
@@ -0,0 +1,17 @@
import { UnistylesRuntime } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
export type WorkspaceRenameSurface = "inline" | "modal";
/**
* Read when a rename starts rather than subscribed to: the surface is chosen once per rename, and
* a subscription would re-render every sidebar row on each breakpoint change.
*
* Native wide layouts keep the modal. Their sidebar rows are touch targets with a permanent
* kebab, and the iPad overlay-dismissal flows drive the modal.
*/
export function resolveWorkspaceRenameSurface(): WorkspaceRenameSurface {
if (!isWeb) return "modal";
const breakpoint = UnistylesRuntime.breakpoint;
return breakpoint === "lg" || breakpoint === "xl" ? "inline" : "modal";
}
@@ -0,0 +1,39 @@
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
// The subset of a workspace a rename needs. Narrower than SidebarWorkspaceEntry so the command
// center can build one from the active route selection without a sidebar row.
export interface RenamableWorkspace {
serverId: string;
workspaceId: string;
name: string;
title?: string | null;
}
/** What the rename field starts with: the custom title, else the derived name it overrides. */
export function workspaceRenameInitialValue(workspace: RenamableWorkspace): string {
return workspace.title ?? workspace.name;
}
/** Resolves to the title the daemon accepted; rejects with a displayable message. */
export function useSetWorkspaceTitle(
workspace: Pick<RenamableWorkspace, "serverId" | "workspaceId">,
): (title: string) => Promise<string | null> {
const { t } = useTranslation();
const mutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
const trimmed = title.trim();
const accepted = await client.setWorkspaceTitle(
workspace.workspaceId,
trimmed.length === 0 ? null : trimmed,
);
return accepted.title;
},
});
return mutation.mutateAsync;
}
@@ -0,0 +1,132 @@
import { type ReactNode, useCallback, useState } from "react";
import { useToast } from "@/contexts/toast-context";
import { InlineTitleField } from "./inline-title-field";
import { WorkspaceRenameModal } from "./modal";
import { resolveWorkspaceRenameSurface } from "./rename-surface";
import {
type RenamableWorkspace,
useSetWorkspaceTitle,
workspaceRenameInitialValue,
} from "./set-workspace-title";
export interface SidebarRenamableWorkspace extends RenamableWorkspace {
workspaceKey: string;
}
/**
* An inline session owns the text editing started from. The field keeps that text while it is
* open, so a rename arriving from elsewhere must not make an untouched field read as an edit.
*
* `saving` and `saved` keep the field on screen holding the submitted text. The daemon answers
* the request before it pushes the updated workspace, so closing on the answer would show the
* old title for a beat. `saved` ends on the first title the row shows after the answer: ours, or
* a later rename the daemon published in its place. The daemon builds each update from its
* latest record, so a later rename can mean ours never appears. `titleSeen` is the row's title as
* of the last render, so a rename delivered before the answer does not end `saved`.
*/
type InlineRenameSession = { kind: "inline"; initialValue: string } & (
| { phase: "editing" }
| { phase: "saving"; titleSeen: string | null }
| { phase: "saved"; titleSeen: string | null; acceptedTitle: string | null }
);
type RenameSession = { kind: "closed" } | { kind: "modal" } | InlineRenameSession;
const CLOSED: RenameSession = { kind: "closed" };
export interface SidebarWorkspaceRename {
/** Starts a rename: in place on wide pointer layouts, in the rename modal otherwise. */
begin: () => void;
/** Rendered in place of the row's title while renaming in place; null otherwise. */
titleEditor: ReactNode | null;
/** Always render this; it is the modal for layouts that rename there. */
modal: ReactNode;
}
export function useSidebarWorkspaceRename(
workspace: SidebarRenamableWorkspace,
): SidebarWorkspaceRename {
const toast = useToast();
const setWorkspaceTitle = useSetWorkspaceTitle(workspace);
const [session, setSession] = useState<RenameSession>(CLOSED);
const currentTitle = workspace.title ?? null;
if (
session.kind === "inline" &&
session.phase === "saving" &&
currentTitle !== session.titleSeen
) {
setSession({ ...session, titleSeen: currentTitle });
}
if (
session.kind === "inline" &&
session.phase === "saved" &&
(currentTitle === session.acceptedTitle || currentTitle !== session.titleSeen)
) {
setSession(CLOSED);
}
const initialValue = workspaceRenameInitialValue(workspace);
const begin = useCallback(() => {
setSession(
resolveWorkspaceRenameSurface() === "inline"
? { kind: "inline", phase: "editing", initialValue }
: { kind: "modal" },
);
}, [initialValue]);
const close = useCallback(() => setSession(CLOSED), []);
const commit = useCallback(
(text: string) => {
if (session.kind !== "inline") return;
const title = text.trim();
if (title.length === 0 || title === session.initialValue) {
setSession(CLOSED);
return;
}
setSession({
kind: "inline",
initialValue: session.initialValue,
phase: "saving",
titleSeen: currentTitle,
});
setWorkspaceTitle(title).then(
(acceptedTitle) =>
setSession((previous) =>
previous.kind === "inline" && previous.phase === "saving"
? { ...previous, phase: "saved", acceptedTitle }
: previous,
),
(error: unknown) => {
setSession(CLOSED);
toast.error(error instanceof Error ? error.message : String(error));
},
);
},
[currentTitle, session, setWorkspaceTitle, toast],
);
const titleEditor =
session.kind === "inline" ? (
<InlineTitleField
initialValue={session.initialValue}
placeholder={workspace.name}
readOnly={session.phase !== "editing"}
onCommit={commit}
onCancel={close}
testID={`sidebar-workspace-rename-input-${workspace.workspaceKey}`}
/>
) : null;
const modal = (
<WorkspaceRenameModal
visible={session.kind === "modal"}
workspace={workspace}
onClose={close}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
);
return { begin, titleEditor, modal };
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"license": "Apache-2.0",
"bin": {
@@ -29,9 +29,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/server": "0.15.4",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/server": "0.16.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"description": "Paseo client SDK package",
"license": "Apache-2.0",
"files": [
@@ -49,8 +49,8 @@
"measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts"
},
"dependencies": {
"@getpaseo/protocol": "0.15.4",
"@getpaseo/relay": "0.15.4",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"license": "Apache-2.0",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"description": "Paseo plugin SDK package",
"license": "Apache-2.0",
"files": [
@@ -78,8 +78,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -89,8 +89,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1
View File
@@ -230,6 +230,7 @@ describe("plugin composer SDK boundary", () => {
composer.setOptions({ effort: null });
composer.setMetadata({ flag: true, stale: null });
composer.insertText("text");
composer.focus({ selection: { start: 0, end: composer.prompt.length } }).then(() => composer.focus());
composer.addAttachment({ sourceId: "issues", item: { id: "1", identifier: "1", title: "t", url: "https://e.com", text: "t", resourceType: "issue" } });
// @ts-expect-error commands take JSON values
composer.setMetadata({ fn: () => {} });
+11
View File
@@ -16,6 +16,12 @@ export interface PluginComposerAttachment {
readonly item: PluginAttachmentItem;
}
/** Where the cursor goes when a composer takes focus. */
export interface PluginComposerFocusOptions {
/** Offsets into `prompt`, clamped to its length. Equal offsets place the cursor. Omit to keep the cursor where it is. */
selection?: { start: number; end: number };
}
/**
* The composer the item sits in: the prompt, the attachments and the agent being configured.
* The commands behave the same in every composer. `context` says where the composer is; it does
@@ -39,6 +45,11 @@ export interface PluginComposer {
setPrompt(text: string): void;
/** Inserts at the cursor, replacing the selection. */
insertText(text: string): void;
/**
* Puts the cursor in this composer, ready to type. Paseo opens the agent's tab or workspace when
* it is not on screen. Resolves once the input has focus; rejects when it could not get there.
*/
focus(options?: PluginComposerFocusOptions): Promise<void>;
addAttachment(input: { sourceId: string; item: PluginAttachmentItem }): void;
removeAttachment(input: { sourceId: string; itemId: string }): void;
/** Resets the model like the built-in control. Rejects on a running agent, which keeps its provider. */
+1
View File
@@ -86,6 +86,7 @@ export { useComposer, useComposers } from "./composer.js";
export type {
PluginComposer,
PluginComposerAttachment,
PluginComposerFocusOptions,
PluginComposerItemContribution,
PluginComposerItemProps,
PluginComposerSelector,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"description": "Paseo shared protocol schemas and wire types",
"license": "Apache-2.0",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"description": "Paseo relay for bridging daemon and client connections",
"license": "Apache-2.0",
"files": [
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.15.4",
"version": "0.16.0-beta.1",
"description": "Paseo backend server",
"license": "Apache-2.0",
"files": [
@@ -109,11 +109,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.4",
"@getpaseo/highlight": "0.15.4",
"@getpaseo/plugin": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/relay": "0.15.4",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/highlight": "0.16.0-beta.1",
"@getpaseo/plugin": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -14285,3 +14285,33 @@ test("turns keep distinct identities across an idle runtime collection and resum
rmSync(workdir, { recursive: true, force: true });
}
});
test("workspace archive preserves a loaded history agent stamp and stamps cascaded children", async () => {
const workdir = mkdtempSync(join(tmpdir(), "workspace-stamp-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
try {
const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const child = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
workspaceId: undefined,
});
const stamp = "2026-11-09T00:00:00.000Z";
await manager.archiveAgent(parent.id, stamp);
expect((await storage.get(parent.id))?.archivedAt).toBe(stamp);
expect((await storage.get(child.id))?.archivedAt).toBe(stamp);
await ensureAgentLoaded(parent.id, { agentManager: manager, agentStorage: storage, logger });
await manager.archiveAgent(parent.id, "2026-11-09T01:00:00.000Z");
expect((await storage.get(parent.id))?.archivedAt).toBe(stamp);
expect((await storage.get(parent.id))?.updatedAt).toBe("2026-11-09T01:00:00.000Z");
} finally {
await storage.flush();
rmSync(workdir, { recursive: true, force: true });
}
});

Some files were not shown because too many files have changed in this diff Show More