mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-12 11:25:36 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c5642827 | ||
|
|
ee6a2bba77 | ||
|
|
83f4ef9909 | ||
|
|
a99f18b405 | ||
|
|
6e5742fbc6 | ||
|
|
97d39a0158 | ||
|
|
8b8957ab77 | ||
|
|
603ee5ced1 | ||
|
|
de1b85ecfb | ||
|
|
33c818cc6b | ||
|
|
de67a7370c | ||
|
|
f684804821 | ||
|
|
e7432cb2a5 | ||
|
|
4aeace7a17 | ||
|
|
e7b31bb746 | ||
|
|
fb435d225f |
+4
-4
@@ -3,15 +3,15 @@
|
||||
## 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))
|
||||
- **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))
|
||||
- **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))
|
||||
- **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))
|
||||
- **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
|
||||
|
||||
|
||||
+4
-4
@@ -3,15 +3,15 @@
|
||||
## 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))
|
||||
- **更快的 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))
|
||||
- **返回时更少重连**:返回应用时会保留仍可用的连接,包括正在传输大型响应的连接,只在必要时重连一次。主机保存了多个连接时,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))
|
||||
- **移动端键盘快捷键**:连接键盘后,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))
|
||||
- **插件侧边栏视图**:插件可以用自己的布局替换侧边栏内容,展示所有已连接主机上的工作区、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))
|
||||
|
||||
### 新增
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+30
-5
@@ -669,9 +669,9 @@ Every section bullet must be scannable at a glance. The changelog is not release
|
||||
## 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, biggest first. The sections below are the
|
||||
full record; a reader who stops after the highlights should have missed nothing they would ask
|
||||
about.
|
||||
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
|
||||
@@ -715,7 +715,7 @@ about.
|
||||
| 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.
|
||||
new?" without its description, and each must outrank the one below it.
|
||||
|
||||
## Changelog attribution
|
||||
|
||||
@@ -748,7 +748,32 @@ Rules:
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
Highlights come first, ordered by user impact. Entries within each section (Added, Improved, Fixed) follow the same order:
|
||||
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
@@ -1 +1 @@
|
||||
sha256-21qteVXVoL7S/wF0aGxHNEkTX8in6UF6+wWEZi8N3HA=
|
||||
sha256-vG7v6uq2ADtOILEuRq9q3wTDUENAhRBbzI7UGBsWOqo=
|
||||
|
||||
Generated
+28
-28
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -753,13 +753,14 @@ class DaemonClientSession {
|
||||
private readonly daemon = new FakeDaemon();
|
||||
private readonly client: DaemonClient;
|
||||
|
||||
constructor() {
|
||||
constructor(options: { livenessHeartbeat?: boolean } = {}) {
|
||||
this.client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_heartbeat_test",
|
||||
logger: noopLogger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => this.daemon.transport,
|
||||
...options,
|
||||
});
|
||||
clients.push(this.client);
|
||||
}
|
||||
@@ -3257,6 +3258,18 @@ test("starts pinging one interval after connecting and holds the cadence", async
|
||||
expect(session.pingTimestamps()).toEqual(["10s", "20s", "30s"]);
|
||||
});
|
||||
|
||||
test("a client with the liveness heartbeat off never pings on its own", async () => {
|
||||
useHeartbeatClock();
|
||||
const session = new DaemonClientSession({ livenessHeartbeat: false });
|
||||
session.daemonAnswersPingsAfter("fast");
|
||||
|
||||
await session.connect();
|
||||
await session.advance(60_000);
|
||||
|
||||
expect(session.state()).toEqual({ status: "connected" });
|
||||
expect(session.pingTimestamps()).toEqual([]);
|
||||
});
|
||||
|
||||
test("stops pinging once the connection is gone", async () => {
|
||||
useHeartbeatClock();
|
||||
const session = new DaemonClientSession();
|
||||
|
||||
@@ -469,6 +469,13 @@ export interface DaemonClientConfig {
|
||||
runtimeMetricsWindowMs?: number;
|
||||
trace?: DaemonClientTrace;
|
||||
capabilities?: Partial<Record<ClientCapability, unknown>>;
|
||||
/**
|
||||
* Ping the daemon every 10 s and reconnect after missed replies (default true). Turn it off
|
||||
* only where the transport reports its own loss (a plugin's IPC channel, an in-process
|
||||
* channel): there a ping cannot find a dead link the channel would not, and it costs both
|
||||
* processes a wakeup per client every 10 s.
|
||||
*/
|
||||
livenessHeartbeat?: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonClientTrace {
|
||||
@@ -2720,7 +2727,11 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private scheduleNextLivenessHeartbeat(): void {
|
||||
if (this.connectionState.status !== "connected" || this.livenessHeartbeatTimer) {
|
||||
if (
|
||||
this.config.livenessHeartbeat === false ||
|
||||
this.connectionState.status !== "connected" ||
|
||||
this.livenessHeartbeatTimer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.livenessHeartbeatTimer = setTimeout(() => {
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -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,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,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": [
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2196,8 +2196,8 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
||||
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId));
|
||||
async archiveAgent(agentId: string, archivedAt?: string): Promise<{ archivedAt: string }> {
|
||||
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId, archivedAt));
|
||||
}
|
||||
|
||||
private async archiveAgentUnlocked(
|
||||
@@ -2234,13 +2234,14 @@ export class AgentManager {
|
||||
throw new Error(`Agent ${agentId} not found in storage after snapshot`);
|
||||
}
|
||||
|
||||
const { archivedAt } = await this.markRecordArchived(stored, requestedArchivedAt);
|
||||
agent.updatedAt = new Date(archivedAt);
|
||||
const archivedRecord = await this.markRecordArchived(stored, requestedArchivedAt);
|
||||
const { archivedAt } = archivedRecord;
|
||||
agent.updatedAt = new Date(archivedRecord.updatedAt);
|
||||
await this.closeAgentRuntime(agentId);
|
||||
await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive");
|
||||
this.discardRetainedAgentState(agentId);
|
||||
|
||||
await this.cascadeArchiveChildren(stored);
|
||||
await this.cascadeArchiveChildren(archivedRecord);
|
||||
|
||||
return { archivedAt };
|
||||
}
|
||||
@@ -2250,7 +2251,7 @@ export class AgentManager {
|
||||
// children so subagent fleets don't outlive their orchestrator. Detached
|
||||
// handoff agents omit this label, so they stand outside the cascade.
|
||||
private async cascadeArchiveChildren(
|
||||
parent: Pick<StoredAgentRecord, "id" | "workspaceId">,
|
||||
parent: Pick<ArchivedStoredAgentRecord, "id" | "workspaceId" | "archivedAt">,
|
||||
): Promise<void> {
|
||||
const registry = this.registry;
|
||||
if (!registry) {
|
||||
@@ -2284,9 +2285,9 @@ export class AgentManager {
|
||||
this.dispatchStoredAgentState(detached.record);
|
||||
}
|
||||
} else if (this.agents.has(currentChild.id)) {
|
||||
await this.archiveAgentUnlocked(currentChild.id);
|
||||
await this.archiveAgentUnlocked(currentChild.id, parent.archivedAt);
|
||||
} else {
|
||||
await this.archiveSnapshotUnlocked(currentChild.id, new Date().toISOString());
|
||||
await this.archiveSnapshotUnlocked(currentChild.id, parent.archivedAt);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2302,7 +2303,7 @@ export class AgentManager {
|
||||
if (!current || getParentAgentIdFromLabels(current.labels) !== parentAgentId) {
|
||||
return;
|
||||
}
|
||||
await this.archiveAgentUnlocked(current.id);
|
||||
await this.archiveAgentUnlocked(current.id, parent.archivedAt);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2312,7 +2313,7 @@ export class AgentManager {
|
||||
archivedAt = new Date().toISOString(),
|
||||
): Promise<ArchivedStoredAgentRecord> {
|
||||
const archivedRecord = await this.persistArchivedRecord(record, {
|
||||
archivedAt,
|
||||
archivedAt: record.archivedAt ?? archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
});
|
||||
|
||||
@@ -2853,7 +2854,7 @@ export class AgentManager {
|
||||
if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord);
|
||||
|
||||
await this.fireAgentArchived(agentId);
|
||||
await this.cascadeArchiveChildren(record);
|
||||
await this.cascadeArchiveChildren(nextRecord);
|
||||
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
@@ -806,6 +806,7 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
},
|
||||
};
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: async () => {},
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
@@ -2526,7 +2527,10 @@ describe("create_agent MCP tool", () => {
|
||||
force: true,
|
||||
reason: "archive-worktree",
|
||||
});
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(
|
||||
"ws-archive-tool-worktree",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
|
||||
["ws-archive-tool-worktree"],
|
||||
expect.any(String),
|
||||
|
||||
@@ -490,7 +490,7 @@ describe("archiveIfSafe", () => {
|
||||
test("records the consumed change request in the workspace archive mutation", async () => {
|
||||
const harness = createHarness({
|
||||
archiveByScope: async (dependencies) => {
|
||||
await dependencies.archiveWorkspaceRecord("ws-auto-archive");
|
||||
await dependencies.archiveWorkspaceRecord("ws-auto-archive", "2026-11-09T00:00:00.000Z");
|
||||
return {
|
||||
archivedAgentIds: [],
|
||||
archivedWorkspaceIds: ["ws-auto-archive"],
|
||||
@@ -501,9 +501,13 @@ describe("archiveIfSafe", () => {
|
||||
|
||||
await runArchiveIfSafe(harness);
|
||||
|
||||
expect(harness.options.archiveWorkspaceRecord).toHaveBeenCalledWith("ws-auto-archive", {
|
||||
autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123",
|
||||
});
|
||||
expect(harness.options.archiveWorkspaceRecord).toHaveBeenCalledWith(
|
||||
"ws-auto-archive",
|
||||
{
|
||||
autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123",
|
||||
},
|
||||
"2026-11-09T00:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
test("archives only the supplied workspace id and does not iterate siblings", async () => {
|
||||
|
||||
@@ -29,7 +29,11 @@ export interface AutoArchiveArchiveOptions {
|
||||
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
getAutoArchivedChangeRequestUrl: (workspaceId: string) => Promise<string | null>;
|
||||
archiveWorkspaceRecord: (workspaceId: string, context?: WorkspaceArchiveContext) => Promise<void>;
|
||||
archiveWorkspaceRecord: (
|
||||
workspaceId: string,
|
||||
context?: WorkspaceArchiveContext,
|
||||
archivedAt?: string,
|
||||
) => Promise<void>;
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
@@ -101,10 +105,14 @@ export async function archiveIfSafe(input: {
|
||||
agentStorage: options.agentStorage,
|
||||
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
|
||||
listActiveWorkspaces: options.listActiveWorkspaces,
|
||||
archiveWorkspaceRecord: (workspaceIdToArchive) =>
|
||||
options.archiveWorkspaceRecord(workspaceIdToArchive, {
|
||||
autoArchivedChangeRequestUrl: pullRequest.url,
|
||||
}),
|
||||
archiveWorkspaceRecord: (workspaceIdToArchive, archivedAt) =>
|
||||
options.archiveWorkspaceRecord(
|
||||
workspaceIdToArchive,
|
||||
{
|
||||
autoArchivedChangeRequestUrl: pullRequest.url,
|
||||
},
|
||||
archivedAt,
|
||||
),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
|
||||
markWorkspaceArchiving: options.markWorkspaceArchiving,
|
||||
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { unarchiveWorkspaceAgents } from "./workspace-archive-service.js";
|
||||
import { createRestartRecovery } from "./agent/restart-recovery/index.js";
|
||||
import { sendPromptToAgent } from "./agent/agent-prompt.js";
|
||||
import { sendPromptToAgent, unarchiveAgentState } from "./agent/agent-prompt.js";
|
||||
import { createIdleRuntimeCleanup } from "./agent/idle-runtime-cleanup/index.js";
|
||||
import {
|
||||
createFinishSubscriptions,
|
||||
@@ -977,6 +978,13 @@ export async function createPaseoDaemon(
|
||||
}
|
||||
});
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: (request) =>
|
||||
unarchiveWorkspaceAgents({
|
||||
...request,
|
||||
agentStorage,
|
||||
unarchiveAgent: (id) => unarchiveAgentState(agentStorage, agentManager, id),
|
||||
sessionLogger: logger,
|
||||
}),
|
||||
lifecycle: pluginRuntime,
|
||||
serverId,
|
||||
projectRegistry,
|
||||
@@ -1102,11 +1110,13 @@ export async function createPaseoDaemon(
|
||||
const archiveWorkspaceRecordExternal = async (
|
||||
workspaceId: string,
|
||||
context?: WorkspaceArchiveContext,
|
||||
archivedAt?: string,
|
||||
) => {
|
||||
const existingWorkspace = await archivePersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
workspaceRegistry,
|
||||
context,
|
||||
archivedAt,
|
||||
});
|
||||
if (!existingWorkspace || existingWorkspace.archivedAt) return;
|
||||
teardownArchivedWorkspaceRuntime(workspaceId);
|
||||
@@ -1301,7 +1311,8 @@ export async function createPaseoDaemon(
|
||||
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
|
||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||
getWorkspace: (workspaceIdToGet) => workspaceRegistry.get(workspaceIdToGet),
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
archiveWorkspaceRecord: (workspaceIdToArchive, archivedAt) =>
|
||||
archiveWorkspaceRecordExternal(workspaceIdToArchive, undefined, archivedAt),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
@@ -1326,7 +1337,7 @@ export async function createPaseoDaemon(
|
||||
archiveAgentCommand({ agentManager, agentStorage, logger }, agentId),
|
||||
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
|
||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
archiveWorkspaceRecord: (workspaceId) => archiveWorkspaceRecordExternal(workspaceId),
|
||||
emit: emitExternalSessionMessage,
|
||||
emitAgentRemove: async () => undefined,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
@@ -1422,7 +1433,8 @@ export async function createPaseoDaemon(
|
||||
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
|
||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||
getWorkspace: (workspaceIdToGet) => workspaceRegistry.get(workspaceIdToGet),
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
archiveWorkspaceRecord: (workspaceIdToArchive, archivedAt) =>
|
||||
archiveWorkspaceRecordExternal(workspaceIdToArchive, undefined, archivedAt),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
@@ -1544,7 +1556,8 @@ export async function createPaseoDaemon(
|
||||
workspaceGitService,
|
||||
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
|
||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
archiveWorkspaceRecord: (workspaceId, archivedAt) =>
|
||||
archiveWorkspaceRecordExternal(workspaceId, undefined, archivedAt),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
|
||||
@@ -1150,6 +1150,7 @@ function createDeps(options?: {
|
||||
};
|
||||
const workspaceGitService = createWorkspaceGitServiceStub();
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: async () => {},
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
|
||||
@@ -1080,75 +1080,91 @@ export default function contribute(server: PluginServerContext) {
|
||||
await service.stopAllPlugins();
|
||||
}, 30_000);
|
||||
|
||||
it.each([false, true])(
|
||||
"cleans a failed update when restoration also fails=%s",
|
||||
async (failRestore) => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-recovery-"));
|
||||
roots.push(home);
|
||||
const repository = await createPlugin("recovery", "export default () => () => {};\n");
|
||||
for (const args of [
|
||||
["init", "-b", "main"],
|
||||
["add", "."],
|
||||
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "initial"],
|
||||
])
|
||||
await runGitCommand(args, { cwd: repository });
|
||||
const running = new Set<string>();
|
||||
let starts = 0;
|
||||
const runtime: TestPluginRuntime = {
|
||||
...emptyUsageRuntime,
|
||||
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
|
||||
invoke: async () => undefined,
|
||||
getLogs: () => [],
|
||||
clearLogs: () => undefined,
|
||||
validatePlugin: async () => undefined,
|
||||
startPlugin: async (id) => {
|
||||
starts++;
|
||||
if (starts === 2) throw new Error("candidate activation failed");
|
||||
if (starts === 3 && failRestore) throw new Error("prior activation failed");
|
||||
running.add(id);
|
||||
},
|
||||
stopPluginById: async (id) => running.delete(id),
|
||||
stopAll: async () => running.clear(),
|
||||
subscribe: () => () => undefined,
|
||||
bindPaseoSessionHost: () => undefined,
|
||||
};
|
||||
const service = createService(
|
||||
home,
|
||||
{},
|
||||
{ runtime, managedSources: new ManagedPluginSources(home) },
|
||||
);
|
||||
await service.start();
|
||||
const installed = await service.installSource({ source: pathToFileURL(repository).href });
|
||||
await writeFile(path.join(repository, "revision.txt"), "new");
|
||||
for (const args of [
|
||||
["add", "."],
|
||||
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "update"],
|
||||
])
|
||||
await runGitCommand(args, { cwd: repository });
|
||||
const result = await applyReviewedUpdate(service, "recovery");
|
||||
expect(result).toMatchObject([
|
||||
{
|
||||
outcome: "error",
|
||||
error: expect.stringContaining(
|
||||
failRestore ? "restoring previous plugin also failed" : "candidate activation failed",
|
||||
),
|
||||
},
|
||||
]);
|
||||
expect(await service.listPlugins()).toMatchObject([
|
||||
{
|
||||
path: installed.path,
|
||||
enabled: true,
|
||||
status: failRestore ? "failed" : "running",
|
||||
installation: installed.installation,
|
||||
},
|
||||
]);
|
||||
expect(await readdir(path.join(home, "plugins", "recovery"))).toEqual([
|
||||
path.basename(path.dirname(installed.path)),
|
||||
]);
|
||||
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
|
||||
await service.stopAllPlugins();
|
||||
describe.each([false, true])(
|
||||
"failed update recovery with restoration failure=%s",
|
||||
(failRestore) => {
|
||||
let home: string;
|
||||
let service: PluginService;
|
||||
let installed: Awaited<ReturnType<PluginService["installSource"]>>;
|
||||
|
||||
// Installing the prior revision and publishing an update have their own fixture budget.
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-recovery-"));
|
||||
roots.push(home);
|
||||
const repository = await createPlugin("recovery", "export default () => () => {};\n");
|
||||
for (const args of [
|
||||
["init", "-b", "main"],
|
||||
["add", "."],
|
||||
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "initial"],
|
||||
])
|
||||
await runGitCommand(args, { cwd: repository });
|
||||
const running = new Set<string>();
|
||||
let starts = 0;
|
||||
const runtime: TestPluginRuntime = {
|
||||
...emptyUsageRuntime,
|
||||
catalog: () => {
|
||||
const entries = [];
|
||||
for (const id of running) entries.push({ id, clientBundle: "bundle" });
|
||||
return entries;
|
||||
},
|
||||
invoke: async () => undefined,
|
||||
getLogs: () => [],
|
||||
clearLogs: () => undefined,
|
||||
validatePlugin: async () => undefined,
|
||||
startPlugin: async (id) => {
|
||||
starts++;
|
||||
if (starts === 2) throw new Error("candidate activation failed");
|
||||
if (starts === 3 && failRestore) throw new Error("prior activation failed");
|
||||
running.add(id);
|
||||
},
|
||||
stopPluginById: async (id) => running.delete(id),
|
||||
stopAll: async () => running.clear(),
|
||||
subscribe: () => () => undefined,
|
||||
bindPaseoSessionHost: () => undefined,
|
||||
};
|
||||
service = createService(
|
||||
home,
|
||||
{},
|
||||
{ runtime, managedSources: new ManagedPluginSources(home) },
|
||||
);
|
||||
await service.start();
|
||||
installed = await service.installSource({ source: pathToFileURL(repository).href });
|
||||
await writeFile(path.join(repository, "revision.txt"), "new");
|
||||
for (const args of [
|
||||
["add", "."],
|
||||
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "update"],
|
||||
])
|
||||
await runGitCommand(args, { cwd: repository });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await service?.stopAllPlugins();
|
||||
});
|
||||
|
||||
it("cleans the failed update and preserves the prior installation", async () => {
|
||||
const result = await applyReviewedUpdate(service, "recovery");
|
||||
expect(result).toMatchObject([
|
||||
{
|
||||
outcome: "error",
|
||||
error: expect.stringContaining(
|
||||
failRestore ? "restoring previous plugin also failed" : "candidate activation failed",
|
||||
),
|
||||
},
|
||||
]);
|
||||
expect(await service.listPlugins()).toMatchObject([
|
||||
{
|
||||
path: installed.path,
|
||||
enabled: true,
|
||||
status: failRestore ? "failed" : "running",
|
||||
installation: installed.installation,
|
||||
},
|
||||
]);
|
||||
expect(await readdir(path.join(home, "plugins", "recovery"))).toEqual([
|
||||
path.basename(path.dirname(installed.path)),
|
||||
]);
|
||||
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
|
||||
}, 30_000);
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("disables and removes a plugin without touching its source directory", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mkdir, rename, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -55,31 +55,53 @@ async function withGitHubFixture(repository: string, run: () => Promise<void>):
|
||||
}
|
||||
}
|
||||
|
||||
describe("managed Git plugin sources", () => {
|
||||
it("does not expose Git URL credentials when cloning fails", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-"));
|
||||
roots.push(home);
|
||||
const sources = new ManagedPluginSources(home);
|
||||
|
||||
// Own the failure response instead of relying on a closed port and OS connect timing.
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(500, { "Content-Type": "text/plain" });
|
||||
response.end("clone rejected for oauth2:super-secret\n");
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as { port: number };
|
||||
// These HTTP fixtures supply credentials in the URL. Ambient helpers can persist
|
||||
// them and mutate shared Git config while other test files read it.
|
||||
function withoutCredentialHelpers(run: () => Promise<void>): () => Promise<void> {
|
||||
return async () => {
|
||||
const previous = process.env.GIT_CONFIG_PARAMETERS;
|
||||
// Credentialed clones replace GIT_CONFIG_COUNT for their URL rewrite. Parameters
|
||||
// survive that overlay and the empty helper resets Git's inherited helper list.
|
||||
process.env.GIT_CONFIG_PARAMETERS = [previous, "'credential.helper='"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
try {
|
||||
const failure = sources.prepareInstall({
|
||||
source: `git:http://oauth2:super-secret@127.0.0.1:${address.port}/missing.git`,
|
||||
});
|
||||
await expect(failure).rejects.toThrow("500");
|
||||
await expect(failure).rejects.toThrow("clone rejected for [redacted]:[redacted]");
|
||||
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
|
||||
await run();
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
if (previous === undefined) delete process.env.GIT_CONFIG_PARAMETERS;
|
||||
else process.env.GIT_CONFIG_PARAMETERS = previous;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
describe("managed Git plugin sources", () => {
|
||||
it(
|
||||
"does not expose Git URL credentials when cloning fails",
|
||||
withoutCredentialHelpers(async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-"));
|
||||
roots.push(home);
|
||||
const sources = new ManagedPluginSources(home);
|
||||
|
||||
// Own the failure response instead of relying on a closed port and OS connect timing.
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(500, { "Content-Type": "text/plain" });
|
||||
response.end("clone rejected for oauth2:super-secret\n");
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as { port: number };
|
||||
try {
|
||||
const failure = sources.prepareInstall({
|
||||
source: `git:http://oauth2:super-secret@127.0.0.1:${address.port}/missing.git`,
|
||||
});
|
||||
await expect(failure).rejects.toThrow("500");
|
||||
await expect(failure).rejects.toThrow("clone rejected for [redacted]:[redacted]");
|
||||
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => {
|
||||
const repository = await createRepository();
|
||||
@@ -227,70 +249,109 @@ describe("managed Git plugin sources", () => {
|
||||
await restarted.preview(id, updated.directory, { kind: "git", ref: initial.slice(0, 8) }),
|
||||
).toMatchObject({ target: { commit: initial } });
|
||||
}, 30_000);
|
||||
it("uses retained acquisition remote when credential rewriting changes origin, preserving nested identity", async () => {
|
||||
const repository = await createRepository();
|
||||
const nested = path.join(repository, "plugins", "review");
|
||||
await mkdir(nested, { recursive: true });
|
||||
for (const name of ["paseo-plugin.json", "index.server.ts"])
|
||||
await rename(path.join(repository, name), path.join(nested, name));
|
||||
await commitAll(repository, "nested plugin");
|
||||
await runGitCommand(["update-server-info"], { cwd: repository });
|
||||
const server = createServer((request, response) => {
|
||||
const pathname = new URL(request.url!, "http://localhost").pathname;
|
||||
const file = path.resolve(repository, ".git", `.${pathname}`);
|
||||
if (!file.startsWith(path.join(repository, ".git") + path.sep)) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
response.end(readFileSync(file));
|
||||
} catch {
|
||||
response.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Expected HTTP port");
|
||||
const remote = `http://fixture:fake-secret@127.0.0.1:${address.port}/`;
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-credential-"));
|
||||
roots.push(home);
|
||||
try {
|
||||
const sources = new ManagedPluginSources(home);
|
||||
const candidate = await sources.place(
|
||||
"nested",
|
||||
await sources.prepareInstall({ source: remote, pluginPath: "plugins/review" }),
|
||||
);
|
||||
sources.commit("nested", candidate.record);
|
||||
expect(
|
||||
(
|
||||
await runGitCommand(["remote", "get-url", "origin"], { cwd: candidate.directory })
|
||||
).stdout.trim(),
|
||||
).toBe("https://paseo.invalid/plugin.git");
|
||||
const before = await sources.describe("nested", candidate.directory);
|
||||
expect(before.identity).toEqual({
|
||||
kind: "git",
|
||||
remote: `http://127.0.0.1:${address.port}/`,
|
||||
pluginPath: "plugins/review",
|
||||
});
|
||||
await writeFile(path.join(nested, "revision.txt"), "new");
|
||||
const latest = await commitAll(repository, "advance");
|
||||
it(
|
||||
"uses retained acquisition remote when credential rewriting changes origin, preserving nested identity",
|
||||
withoutCredentialHelpers(async () => {
|
||||
const repository = await createRepository();
|
||||
const nested = path.join(repository, "plugins", "review");
|
||||
await mkdir(nested, { recursive: true });
|
||||
for (const name of ["paseo-plugin.json", "index.server.ts"])
|
||||
await rename(path.join(repository, name), path.join(nested, name));
|
||||
await commitAll(repository, "nested plugin");
|
||||
await runGitCommand(["update-server-info"], { cwd: repository });
|
||||
const restarted = new ManagedPluginSources(home);
|
||||
const preview = await restarted.preview("nested", candidate.directory);
|
||||
expect(preview.target).toEqual({ kind: "git", commit: latest });
|
||||
const updated = await restarted.place(
|
||||
"nested",
|
||||
await restarted.prepareUpdate(preview.proposal!, candidate.directory),
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-credential-"));
|
||||
roots.push(home);
|
||||
const helper = path.join(home, "credential-helper.cjs");
|
||||
const helperCalls = path.join(home, "credential-helper-calls");
|
||||
const globalConfig = path.join(home, "gitconfig");
|
||||
await writeFile(
|
||||
helper,
|
||||
'require("node:fs").writeFileSync(process.argv[2], process.argv[3]);\n',
|
||||
);
|
||||
expect(await restarted.describe("nested", updated.directory)).toEqual({
|
||||
identity: before.identity,
|
||||
currentRevision: latest,
|
||||
const helperCommand =
|
||||
"!" +
|
||||
[process.execPath, helper, helperCalls]
|
||||
.map((argument) => `'${argument.replaceAll("'", "'\\''")}'`)
|
||||
.join(" ");
|
||||
await writeFile(globalConfig, "");
|
||||
await runGitCommand(["config", "--file", globalConfig, "credential.helper", ""], {
|
||||
cwd: repository,
|
||||
});
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}, 30_000);
|
||||
await runGitCommand(
|
||||
["config", "--file", globalConfig, "--add", "credential.helper", helperCommand],
|
||||
{ cwd: repository },
|
||||
);
|
||||
const server = createServer((request, response) => {
|
||||
const pathname = new URL(request.url!, "http://localhost").pathname;
|
||||
const file = path.resolve(repository, ".git", `.${pathname}`);
|
||||
if (!file.startsWith(path.join(repository, ".git") + path.sep)) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
response.end(readFileSync(file));
|
||||
} catch {
|
||||
response.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Expected HTTP port");
|
||||
const remote = `http://fixture:fake-secret@127.0.0.1:${address.port}/`;
|
||||
const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL;
|
||||
process.env.GIT_CONFIG_GLOBAL = globalConfig;
|
||||
try {
|
||||
const sources = new ManagedPluginSources(home);
|
||||
const candidate = await sources.place(
|
||||
"nested",
|
||||
await sources.prepareInstall({ source: remote, pluginPath: "plugins/review" }),
|
||||
);
|
||||
sources.commit("nested", candidate.record);
|
||||
expect(
|
||||
(
|
||||
await runGitCommand(["remote", "get-url", "origin"], { cwd: candidate.directory })
|
||||
).stdout.trim(),
|
||||
).toBe("https://paseo.invalid/plugin.git");
|
||||
const before = await sources.describe("nested", candidate.directory);
|
||||
expect(before.identity).toEqual({
|
||||
kind: "git",
|
||||
remote: `http://127.0.0.1:${address.port}/`,
|
||||
pluginPath: "plugins/review",
|
||||
});
|
||||
await writeFile(path.join(nested, "revision.txt"), "new");
|
||||
const latest = await commitAll(repository, "advance");
|
||||
await runGitCommand(["update-server-info"], { cwd: repository });
|
||||
const restarted = new ManagedPluginSources(home);
|
||||
const preview = await restarted.preview("nested", candidate.directory);
|
||||
expect(preview.target).toEqual({ kind: "git", commit: latest });
|
||||
const updated = await restarted.place(
|
||||
"nested",
|
||||
await restarted.prepareUpdate(preview.proposal!, candidate.directory),
|
||||
);
|
||||
expect(await restarted.describe("nested", updated.directory)).toEqual({
|
||||
identity: before.identity,
|
||||
currentRevision: latest,
|
||||
});
|
||||
expect(
|
||||
existsSync(helperCalls),
|
||||
"HTTP fixture must not invoke ambient credential helpers",
|
||||
).toBe(false);
|
||||
// Verify the sentinel can run on this platform, so a broken executable
|
||||
// cannot make the no-invocation assertion pass without a working guard.
|
||||
await runGitCommand(["-c", `credential.helper=${helperCommand}`, "credential", "approve"], {
|
||||
cwd: repository,
|
||||
input: "protocol=http\nhost=127.0.0.1\nusername=fixture\npassword=fake-secret\n\n",
|
||||
});
|
||||
expect(await readFile(helperCalls, "utf8")).toBe("store");
|
||||
} finally {
|
||||
if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL;
|
||||
else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig;
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}),
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
|
||||
describe("registry plugin sources", () => {
|
||||
|
||||
@@ -313,6 +313,9 @@ export function createPluginWorker(options: {
|
||||
// The runtime re-attaches a session when the daemon drops this socket.
|
||||
reconnect: { enabled: true },
|
||||
transportFactory,
|
||||
// The channel is a pipe to the daemon (or an in-process queue for built-in plugins); its
|
||||
// loss is reported directly, so a 10 s ping would only wake both sides.
|
||||
livenessHeartbeat: false,
|
||||
});
|
||||
paseo = createPaseoApi(daemonClient);
|
||||
await daemonClient.connect();
|
||||
@@ -643,6 +646,7 @@ export function createPluginWorker(options: {
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
let worker: ReturnType<typeof createPluginWorker> | null = null;
|
||||
let closedByShutdown = false;
|
||||
const handlers = new Set<(message: PluginProcessRequest) => void>();
|
||||
const channel: PluginWorkerChannel = {
|
||||
send(message, callback) {
|
||||
@@ -656,9 +660,17 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
};
|
||||
},
|
||||
disconnect() {
|
||||
closedByShutdown = true;
|
||||
process.disconnect();
|
||||
},
|
||||
};
|
||||
// The daemon worker owns this process through the IPC channel. When the channel closes without
|
||||
// this process closing it (the worker crashed or was killed), nothing can reach the plugin any
|
||||
// more, so exit instead of running on as an orphan. A normal shutdown closes the channel itself
|
||||
// and lets work still in flight finish.
|
||||
process.once("disconnect", () => {
|
||||
if (!closedByShutdown) process.exit(0);
|
||||
});
|
||||
process.on("message", (raw) => {
|
||||
const parsed = PluginProcessRequestSchema.safeParse(raw);
|
||||
if (!worker && parsed.success && parsed.data.type === "initialize") {
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import pino from "pino";
|
||||
import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import type { AgentStreamEvent } from "../agent/agent-sdk-types.js";
|
||||
import { PluginAgentClientRegistry } from "../agent/plugin-provider.js";
|
||||
import { PluginRuntime } from "./runtime.js";
|
||||
@@ -1936,6 +1936,72 @@ export default function contribute(plugin: any) {
|
||||
);
|
||||
});
|
||||
|
||||
it("exits a plugin process when its channel to the daemon closes without a shutdown", async () => {
|
||||
const directory = await createPlugin(
|
||||
"orphaned",
|
||||
// A timer keeps the process alive the way a polling plugin does.
|
||||
`export default function contribute(plugin: unknown) {
|
||||
void plugin;
|
||||
const timer = setInterval(() => undefined, 1_000);
|
||||
return () => clearInterval(timer);
|
||||
}`,
|
||||
);
|
||||
const spawned: Array<ReturnType<typeof spawnObservedPluginProcess>> = [];
|
||||
const exits: Array<Promise<number | null>> = [];
|
||||
const runtime = createTestRuntime({
|
||||
spawnChild: () => {
|
||||
const child = spawnObservedPluginProcess(() => undefined);
|
||||
exits.push(new Promise((resolve) => child.once("exit", resolve)));
|
||||
spawned.push(child);
|
||||
return child;
|
||||
},
|
||||
});
|
||||
// If the process never exits (the failure this test catches), do not leave it running.
|
||||
onTestFinished(() => {
|
||||
for (const child of spawned) if (child.exitCode === null) child.kill("SIGKILL");
|
||||
});
|
||||
await runtime.startPlugin("orphaned", directory);
|
||||
expect(spawned).toHaveLength(1);
|
||||
|
||||
// What the plugin sees when its daemon worker dies: the IPC channel closes, no shutdown request.
|
||||
spawned[0]!.disconnect();
|
||||
|
||||
// In a real crash the daemon side is gone, so there is no runtime state left to stop here.
|
||||
await expect(exits[0]).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("reports and removes a plugin it terminated for an invalid message", async () => {
|
||||
const directory = await createPlugin(
|
||||
"invalid-message",
|
||||
`export default function contribute(plugin: unknown) {
|
||||
void plugin;
|
||||
setTimeout(() => process.send?.({ type: "not-a-plugin-message" }), 20);
|
||||
return () => undefined;
|
||||
}`,
|
||||
);
|
||||
const sessions = createTrackedSessionHost();
|
||||
const runtime = createTestRuntime({ sessionHost: sessions.host });
|
||||
const exited = new Promise<string>((resolve) => {
|
||||
runtime.subscribe((pluginId, error) => {
|
||||
if (pluginId === "invalid-message" && error) resolve(error);
|
||||
});
|
||||
});
|
||||
await runtime.startPlugin("invalid-message", directory);
|
||||
|
||||
// The runtime kills the process; it must then treat it like any other exited plugin.
|
||||
await expect(exited).resolves.toBe("Plugin process exited: invalid-message");
|
||||
expect(sessions.active.size).toBe(0);
|
||||
expect(runtime.catalog()).toEqual([]);
|
||||
expect(
|
||||
pluginMessages(runtime, "invalid-message").some((message) =>
|
||||
message.includes("sent an invalid message"),
|
||||
),
|
||||
).toBe(true);
|
||||
await expect(runtime.invoke("invalid-message", "anything", {})).rejects.toThrow(
|
||||
"Plugin is not available",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-attaches a plugin session when the daemon closes the socket under a live child", async () => {
|
||||
const directory = await createPlugin(
|
||||
"reattaching",
|
||||
|
||||
@@ -247,8 +247,9 @@ function spawnPluginChild(): PluginChild {
|
||||
}) as PluginChild;
|
||||
}
|
||||
|
||||
// Kill only. Node never emits "close" for a child whose IPC channel the parent disconnected, and
|
||||
// "close" is how the runtime learns that a plugin exited; the channel closes with the process.
|
||||
function terminatePluginChild(child: PluginChild): void {
|
||||
if (child.connected) child.disconnect();
|
||||
if (!child.killed) child.kill();
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
|
||||
await projectRegistry.initialize();
|
||||
const workspaceGitService = createNoopWorkspaceGitService();
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: async () => {},
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
|
||||
@@ -219,6 +219,7 @@ import type { PushNotifications } from "./push/index.js";
|
||||
import {
|
||||
archivePersistedWorkspaceRecord,
|
||||
archiveWorkspaceContents,
|
||||
unarchiveWorkspaceAgents,
|
||||
} from "./workspace-archive-service.js";
|
||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import { renameCurrentBranch as renameCurrentBranchDefault } from "../utils/checkout-git.js";
|
||||
@@ -1003,6 +1004,13 @@ export class Session {
|
||||
});
|
||||
this.workspaceAutoName = workspaceAutoName;
|
||||
this.workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: (request) =>
|
||||
unarchiveWorkspaceAgents({
|
||||
...request,
|
||||
agentStorage: this.agentStorage,
|
||||
unarchiveAgent: (id) => unarchiveAgentState(this.agentStorage, this.agentManager, id),
|
||||
sessionLogger: this.sessionLogger,
|
||||
}),
|
||||
withCheckoutLock: this.workspaceBaseBranch.withCheckoutLock,
|
||||
lifecycle: this.pluginRuntime,
|
||||
serverId,
|
||||
@@ -4074,6 +4082,7 @@ export class Session {
|
||||
const removedWorkspaceIds: string[] = [];
|
||||
try {
|
||||
for (const workspaceId of activeWorkspaceIds) {
|
||||
const archivedAt = new Date().toISOString();
|
||||
await archiveWorkspaceContents(
|
||||
{
|
||||
agentManager: this.agentManager,
|
||||
@@ -4083,8 +4092,9 @@ export class Session {
|
||||
sessionLogger: this.sessionLogger,
|
||||
},
|
||||
workspaceId,
|
||||
archivedAt,
|
||||
);
|
||||
await this.archiveWorkspaceRecord(workspaceId);
|
||||
await this.archiveWorkspaceRecord(workspaceId, archivedAt);
|
||||
removedWorkspaceIds.push(workspaceId);
|
||||
}
|
||||
|
||||
@@ -5678,7 +5688,8 @@ export class Session {
|
||||
agentStorage: this.agentStorage,
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
archiveWorkspaceRecord: (workspaceId, archivedAt) =>
|
||||
this.archiveWorkspaceRecord(workspaceId, archivedAt),
|
||||
emit: (message) => this.emit(message),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
|
||||
@@ -8019,7 +8030,8 @@ export class Session {
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
archiveWorkspaceRecord: (workspaceId, archivedAt) =>
|
||||
this.archiveWorkspaceRecord(workspaceId, archivedAt),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
|
||||
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
||||
@@ -8130,19 +8142,8 @@ export class Session {
|
||||
}
|
||||
if (workspace.archivedAt) {
|
||||
await this.workspaceRecovery.restore(workspaceId);
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
input.agentIds.map((agentId) =>
|
||||
unarchiveAgentState(this.agentStorage, this.agentManager, agentId),
|
||||
),
|
||||
);
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === "rejected") {
|
||||
this.sessionLogger.warn(
|
||||
{ err: result.reason, workspaceId, agentId: input.agentIds[index] },
|
||||
"Failed to unarchive agent while undoing workspace archive",
|
||||
);
|
||||
}
|
||||
} else if (input.archivedAt) {
|
||||
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace, input.archivedAt);
|
||||
}
|
||||
await this.publishRecoveredWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
+51
@@ -19,6 +19,7 @@ import {
|
||||
FileBackedProjectRegistry,
|
||||
FileBackedWorkspaceRegistry,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type WorkspaceRegistry,
|
||||
} from "../../workspace-registry.js";
|
||||
@@ -118,6 +119,7 @@ beforeEach(async () => {
|
||||
await workspaceRegistry.initialize();
|
||||
await projectRegistry.initialize();
|
||||
provisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: async () => {},
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: gitService(),
|
||||
@@ -169,6 +171,7 @@ test("a workspace created during a base change records the new base", async () =
|
||||
});
|
||||
await locked;
|
||||
const creator = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: async () => {},
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: git,
|
||||
@@ -285,6 +288,7 @@ test("background workspace creation preserves hooks, inheritance and durable vis
|
||||
const dir = path.join(tmpDir, "plain");
|
||||
const events: string[] = [];
|
||||
provisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: async () => {},
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: gitService(),
|
||||
@@ -374,6 +378,7 @@ test("persists manual worktree ownership separately from its workspace kind", as
|
||||
const cwd = path.join(tmpDir, "manual-worktree");
|
||||
const mainRepoRoot = path.join(tmpDir, "main-repo");
|
||||
const manualWorktreeProvisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: async () => {},
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
isDirectory,
|
||||
@@ -461,6 +466,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
|
||||
await workspaceRegistry.upsert(workspace);
|
||||
await projectRegistry.archive(project.projectId, ARCHIVED_AT);
|
||||
const archivedProvisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: async () => {},
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
isDirectory,
|
||||
@@ -513,6 +519,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
|
||||
remove: (workspaceId) => workspaceRegistry.remove(workspaceId),
|
||||
};
|
||||
const snapshotProvisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: async () => {},
|
||||
workspaceRegistry: snapshotRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: gitService(),
|
||||
@@ -1016,6 +1023,7 @@ test("a failed import keeps an archived worktree's placement when its directory
|
||||
});
|
||||
await workspaceRegistry.upsert(archived);
|
||||
const realCheckoutProvisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: async () => {},
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
logger,
|
||||
@@ -1089,6 +1097,7 @@ test("a directory that goes away while the git read is in flight keeps its place
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/vanishing" });
|
||||
const vanishingProvisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: async () => {},
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
isDirectory,
|
||||
@@ -1117,3 +1126,45 @@ test("a directory that goes away while the git read is in flight keeps its place
|
||||
branch: "feature/vanishing",
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed workspace unarchive write keeps agents archived and permits retry", async () => {
|
||||
const repo = path.join(tmpDir, "restore-write-failure");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
const workspace = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.archive(workspace.workspaceId, ARCHIVED_AT);
|
||||
const archived = (await workspaceRegistry.get(workspace.workspaceId))!;
|
||||
|
||||
class FailingWorkspaceRegistry extends FileBackedWorkspaceRegistry {
|
||||
failWrites = true;
|
||||
override async upsert(record: PersistedWorkspaceRecord): Promise<void> {
|
||||
if (this.failWrites && !record.archivedAt) throw new Error("Workspace write failed");
|
||||
await super.upsert(record);
|
||||
}
|
||||
}
|
||||
const registry = new FailingWorkspaceRegistry(
|
||||
path.join(tmpDir, "projects", "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
await registry.initialize();
|
||||
let agentArchived = true;
|
||||
const recovery = createWorkspaceProvisioningService({
|
||||
workspaceRegistry: registry,
|
||||
projectRegistry,
|
||||
workspaceGitService: gitService(),
|
||||
isDirectory,
|
||||
logger,
|
||||
unarchiveWorkspaceAgents: async () => {
|
||||
agentArchived = false;
|
||||
},
|
||||
});
|
||||
await expect(recovery.ensureWorkspaceRecordUnarchived(archived)).rejects.toThrow(
|
||||
"Workspace write failed",
|
||||
);
|
||||
expect((await registry.get(workspace.workspaceId))?.archivedAt).toBe(ARCHIVED_AT);
|
||||
expect(agentArchived).toBe(true);
|
||||
|
||||
registry.failWrites = false;
|
||||
await recovery.ensureWorkspaceRecordUnarchived(archived);
|
||||
expect((await registry.get(workspace.workspaceId))?.archivedAt).toBeNull();
|
||||
expect(agentArchived).toBe(false);
|
||||
});
|
||||
|
||||
+14
-3
@@ -1,3 +1,4 @@
|
||||
import type { WorkspaceArchiveRestoreRequest } from "../../workspace-archive-service.js";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginLifecycle } from "../../plugins/lifecycle/index.js";
|
||||
import { describeHookWorkspace } from "../../plugins/lifecycle/index.js";
|
||||
@@ -84,6 +85,7 @@ export interface WorkspaceProvisioningService {
|
||||
findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord>;
|
||||
ensureWorkspaceRecordUnarchived(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
archivedAt?: string | null,
|
||||
): Promise<PersistedWorkspaceRecord>;
|
||||
}
|
||||
|
||||
@@ -104,6 +106,7 @@ export class WorkspaceProvisioningError extends Error {
|
||||
}
|
||||
|
||||
export function createWorkspaceProvisioningService(deps: {
|
||||
unarchiveWorkspaceAgents: (request: WorkspaceArchiveRestoreRequest) => Promise<void>;
|
||||
serverId?: string;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
projectRegistry: ProjectRegistry;
|
||||
@@ -433,6 +436,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
|
||||
async function ensureWorkspaceRecordUnarchived(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
archivedAt = workspace.archivedAt,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
if (!project) throw new Error(`Unknown project: ${workspace.projectId}`);
|
||||
@@ -480,9 +484,16 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!next) return workspace;
|
||||
await workspaceRegistry.upsert(next);
|
||||
return next;
|
||||
if (next) await workspaceRegistry.upsert(next);
|
||||
// A canceled archive supplies a modern stamp before the record itself is archived.
|
||||
if (archivedAt) {
|
||||
await deps.unarchiveWorkspaceAgents({
|
||||
workspaceId: workspace.workspaceId,
|
||||
archivedAt,
|
||||
allowLegacyFallback: Boolean(workspace.archivedAt),
|
||||
});
|
||||
}
|
||||
return next ?? workspace;
|
||||
}
|
||||
|
||||
async function refreshWorkspaceRecord(
|
||||
|
||||
@@ -19,10 +19,9 @@ function deferred<T = void>(): Deferred<T> {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function archived(newlyArchivedAgentIds: string[]): ArchiveResult {
|
||||
function archived(agentIds: string[]): ArchiveResult {
|
||||
return {
|
||||
archivedAgentIds: newlyArchivedAgentIds,
|
||||
newlyArchivedAgentIds,
|
||||
archivedAgentIds: agentIds,
|
||||
archivedWorkspaceIds: ["ws"],
|
||||
removedDirectory: false,
|
||||
};
|
||||
@@ -46,7 +45,7 @@ function parkedArchive(agentIds: string[]) {
|
||||
signal = runSignal;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
if (runSignal.aborted) throw new WorkspaceArchiveCanceledError(agentIds, false);
|
||||
if (runSignal.aborted) throw new WorkspaceArchiveCanceledError("stamp");
|
||||
return archived(agentIds);
|
||||
},
|
||||
};
|
||||
@@ -56,33 +55,33 @@ describe("WorkspaceArchiveOperations", () => {
|
||||
test("an undo during a running archive aborts it and restores the agents it took down", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
const archive = parkedArchive(["agent-a", "agent-b"]);
|
||||
const restored: string[][] = [];
|
||||
const restored: Array<string | undefined> = [];
|
||||
|
||||
const archiveDone = operations.archive("ws", archive.run);
|
||||
await archive.started;
|
||||
const undoDone = operations.undo("ws", async ({ agentIds }) => {
|
||||
restored.push(agentIds);
|
||||
const undoDone = operations.undo("ws", async ({ archivedAt }) => {
|
||||
restored.push(archivedAt);
|
||||
});
|
||||
|
||||
expect(archive.aborted).toBe(true);
|
||||
expect(restored).toEqual([]);
|
||||
archive.release();
|
||||
|
||||
await expect(archiveDone).resolves.toEqual({ kind: "canceled" });
|
||||
await expect(archiveDone).resolves.toEqual({ kind: "canceled", archivedAt: "stamp" });
|
||||
await undoDone;
|
||||
expect(restored).toEqual([["agent-a", "agent-b"]]);
|
||||
expect(restored).toEqual(["stamp"]);
|
||||
});
|
||||
|
||||
test("an undo after the archive finished restores the agents that archive took down", async () => {
|
||||
test("an undo after archive completion delegates restoration to persisted state", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
await operations.archive("ws", async () => archived(["agent-a"]));
|
||||
|
||||
const restored: string[][] = [];
|
||||
await operations.undo("ws", async ({ agentIds }) => {
|
||||
restored.push(agentIds);
|
||||
const restored: Array<string | undefined> = [];
|
||||
await operations.undo("ws", async ({ archivedAt }) => {
|
||||
restored.push(archivedAt);
|
||||
});
|
||||
|
||||
expect(restored).toEqual([["agent-a"]]);
|
||||
expect(restored).toEqual([undefined]);
|
||||
});
|
||||
|
||||
test("restore runs only after the aborted archive has stopped", async () => {
|
||||
@@ -153,21 +152,21 @@ describe("WorkspaceArchiveOperations", () => {
|
||||
test("archive, undo, archive in quick succession leaves the workspace archived", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
const first = parkedArchive(["agent-a"]);
|
||||
const restored: string[][] = [];
|
||||
const restored: Array<string | undefined> = [];
|
||||
|
||||
const firstDone = operations.archive("ws", first.run);
|
||||
await first.started;
|
||||
const undoDone = operations.undo("ws", async ({ agentIds }) => {
|
||||
restored.push(agentIds);
|
||||
const undoDone = operations.undo("ws", async ({ archivedAt }) => {
|
||||
restored.push(archivedAt);
|
||||
});
|
||||
// The re-archive must not join the archive the undo just aborted.
|
||||
const secondDone = operations.archive("ws", async () => archived(["agent-a"]));
|
||||
first.release();
|
||||
|
||||
await expect(firstDone).resolves.toEqual({ kind: "canceled" });
|
||||
await expect(firstDone).resolves.toEqual({ kind: "canceled", archivedAt: "stamp" });
|
||||
await undoDone;
|
||||
await expect(secondDone).resolves.toMatchObject({ kind: "archived" });
|
||||
expect(restored).toEqual([["agent-a"]]);
|
||||
expect(restored).toEqual(["stamp"]);
|
||||
});
|
||||
|
||||
test("a second undo while one runs joins it", async () => {
|
||||
@@ -188,7 +187,7 @@ describe("WorkspaceArchiveOperations", () => {
|
||||
expect(restores).toBe(1);
|
||||
});
|
||||
|
||||
test("a failed restore keeps the agents for a retry", async () => {
|
||||
test("a failed restore frees the lane for retry", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
await operations.archive("ws", async () => archived(["agent-a"]));
|
||||
|
||||
@@ -198,56 +197,51 @@ describe("WorkspaceArchiveOperations", () => {
|
||||
}),
|
||||
).rejects.toThrow("worktree recreation failed");
|
||||
|
||||
const restored: string[][] = [];
|
||||
await operations.undo("ws", async ({ agentIds }) => {
|
||||
restored.push(agentIds);
|
||||
const restored: Array<string | undefined> = [];
|
||||
await operations.undo("ws", async ({ archivedAt }) => {
|
||||
restored.push(archivedAt);
|
||||
});
|
||||
expect(restored).toEqual([["agent-a"]]);
|
||||
expect(restored).toEqual([undefined]);
|
||||
});
|
||||
|
||||
test("an archive of an already archived workspace keeps the earlier archive's agents for undo", async () => {
|
||||
test("a repeat archive still delegates undo to persisted state", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
await operations.archive("ws", async () => archived(["agent-a"]));
|
||||
await operations.archive("ws", async () => ({
|
||||
archivedAgentIds: ["agent-a"],
|
||||
newlyArchivedAgentIds: [],
|
||||
archivedWorkspaceIds: [],
|
||||
removedDirectory: false,
|
||||
}));
|
||||
const restored: string[][] = [];
|
||||
const restored: Array<string | undefined> = [];
|
||||
|
||||
await operations.undo("ws", async ({ agentIds }) => {
|
||||
restored.push(agentIds);
|
||||
await operations.undo("ws", async ({ archivedAt }) => {
|
||||
restored.push(archivedAt);
|
||||
});
|
||||
|
||||
expect(restored).toEqual([["agent-a"]]);
|
||||
expect(restored).toEqual([undefined]);
|
||||
});
|
||||
|
||||
test("an undo restores the archive's agents once; a repeat undo restores none", async () => {
|
||||
test("each completed undo reads the current persisted state", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
await operations.archive("ws", async () => archived(["agent-a"]));
|
||||
const restored: string[][] = [];
|
||||
const restore = async ({ agentIds }: { agentIds: string[] }) => {
|
||||
restored.push(agentIds);
|
||||
const restored: Array<string | undefined> = [];
|
||||
const restore = async ({ archivedAt }: { archivedAt?: string }) => {
|
||||
restored.push(archivedAt);
|
||||
};
|
||||
|
||||
await operations.undo("ws", restore);
|
||||
await operations.undo("ws", restore);
|
||||
|
||||
expect(restored).toEqual([["agent-a"], []]);
|
||||
expect(restored).toEqual([undefined, undefined]);
|
||||
});
|
||||
|
||||
test("an undo after the retention window restores the workspace without agents", async () => {
|
||||
let now = 0;
|
||||
const operations = new WorkspaceArchiveOperations({ undoRetentionMs: 1000, now: () => now });
|
||||
await operations.archive("ws", async () => archived(["agent-a"]));
|
||||
now = 1000;
|
||||
|
||||
const restored: string[][] = [];
|
||||
await operations.undo("ws", async ({ agentIds }) => {
|
||||
restored.push(agentIds);
|
||||
test("a fresh daemon delegates undo to persisted workspace state", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
const inputs: unknown[] = [];
|
||||
await operations.undo("ws", async (input) => {
|
||||
inputs.push(input);
|
||||
});
|
||||
expect(restored).toEqual([[]]);
|
||||
expect(inputs).toEqual([{ archivedAt: undefined }]);
|
||||
});
|
||||
|
||||
test("workspaces have independent lanes", async () => {
|
||||
|
||||
@@ -2,24 +2,16 @@ import { type ArchiveResult, WorkspaceArchiveCanceledError } from "./workspace-a
|
||||
|
||||
export type WorkspaceArchiveOutcome =
|
||||
| { kind: "archived"; result: ArchiveResult }
|
||||
| { kind: "canceled" };
|
||||
| { kind: "canceled"; archivedAt: string };
|
||||
|
||||
export interface WorkspaceArchiveUndoInput {
|
||||
/** Agents the undone archive took down. Empty when the daemon no longer remembers it. */
|
||||
agentIds: string[];
|
||||
archivedAt?: string;
|
||||
}
|
||||
|
||||
type LaneEntry =
|
||||
| { kind: "archiving"; controller: AbortController; done: Promise<WorkspaceArchiveOutcome> }
|
||||
| { kind: "undoing"; done: Promise<void> };
|
||||
|
||||
interface UndoableArchive {
|
||||
agentIds: string[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const DEFAULT_UNDO_RETENTION_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* One lane per workspace for archive and its undo, shared by every session on the daemon.
|
||||
*
|
||||
@@ -30,21 +22,9 @@ const DEFAULT_UNDO_RETENTION_MS = 10 * 60_000;
|
||||
*
|
||||
* Each call claims the lane synchronously, so whichever request arrives last decides the
|
||||
* workspace's final state no matter how the earlier work interleaves.
|
||||
*
|
||||
* The agents an archive took down are kept in memory for the undo window. An archive that took
|
||||
* nothing down, such as a repeat archive from a stale client, leaves them alone. After a daemon
|
||||
* restart, undo still restores the workspace but its agents stay archived.
|
||||
*/
|
||||
export class WorkspaceArchiveOperations {
|
||||
private readonly lanes = new Map<string, LaneEntry>();
|
||||
private readonly undoable = new Map<string, UndoableArchive>();
|
||||
private readonly undoRetentionMs: number;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: { undoRetentionMs?: number; now?: () => number } = {}) {
|
||||
this.undoRetentionMs = options.undoRetentionMs ?? DEFAULT_UNDO_RETENTION_MS;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
archive(
|
||||
workspaceId: string,
|
||||
@@ -59,16 +39,10 @@ export class WorkspaceArchiveOperations {
|
||||
await settled(current?.done);
|
||||
try {
|
||||
const result = await run(controller.signal);
|
||||
if (result.archivedWorkspaceIds.length > 0 || result.newlyArchivedAgentIds.length > 0) {
|
||||
this.retainUndoable(workspaceId, result.newlyArchivedAgentIds);
|
||||
}
|
||||
return { kind: "archived", result };
|
||||
} catch (error) {
|
||||
if (!(error instanceof WorkspaceArchiveCanceledError)) throw error;
|
||||
if (error.recordArchived || error.newlyArchivedAgentIds.length > 0) {
|
||||
this.retainUndoable(workspaceId, error.newlyArchivedAgentIds);
|
||||
}
|
||||
return { kind: "canceled" };
|
||||
return { kind: "canceled", archivedAt: error.archivedAt };
|
||||
}
|
||||
})();
|
||||
this.claim(workspaceId, { kind: "archiving", controller, done });
|
||||
@@ -85,15 +59,9 @@ export class WorkspaceArchiveOperations {
|
||||
}
|
||||
current?.controller.abort();
|
||||
const done = (async () => {
|
||||
await settled(current?.done);
|
||||
const agentIds = this.takeUndoable(workspaceId);
|
||||
try {
|
||||
await restore({ agentIds });
|
||||
} catch (error) {
|
||||
// Keep the agents for a retry; the archive they came from is still the latest one.
|
||||
this.retainUndoable(workspaceId, agentIds);
|
||||
throw error;
|
||||
}
|
||||
const outcome =
|
||||
current?.kind === "archiving" ? await current.done.catch(() => undefined) : undefined;
|
||||
await restore({ archivedAt: outcome?.kind === "canceled" ? outcome.archivedAt : undefined });
|
||||
})();
|
||||
this.claim(workspaceId, { kind: "undoing", done });
|
||||
return done;
|
||||
@@ -106,21 +74,6 @@ export class WorkspaceArchiveOperations {
|
||||
};
|
||||
void (entry.done as Promise<unknown>).then(release, release);
|
||||
}
|
||||
|
||||
private retainUndoable(workspaceId: string, agentIds: string[]): void {
|
||||
const now = this.now();
|
||||
for (const [id, entry] of this.undoable) {
|
||||
if (entry.expiresAt <= now) this.undoable.delete(id);
|
||||
}
|
||||
this.undoable.set(workspaceId, { agentIds, expiresAt: now + this.undoRetentionMs });
|
||||
}
|
||||
|
||||
private takeUndoable(workspaceId: string): string[] {
|
||||
const entry = this.undoable.get(workspaceId);
|
||||
this.undoable.delete(workspaceId);
|
||||
if (!entry || entry.expiresAt <= this.now()) return [];
|
||||
return entry.agentIds;
|
||||
}
|
||||
}
|
||||
|
||||
async function settled(promise: Promise<unknown> | undefined): Promise<void> {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import {
|
||||
archiveByScope,
|
||||
unarchiveWorkspaceAgents,
|
||||
type ActiveWorkspaceRef,
|
||||
type ArchiveDependencies,
|
||||
type ArchiveResult,
|
||||
@@ -1059,7 +1060,7 @@ describe("archiveByScope cancellation", () => {
|
||||
} as Pick<AgentStorage, "listByWorkspace">;
|
||||
}
|
||||
|
||||
test("an abort while agents archive keeps the record and reports only the agents it took down", async () => {
|
||||
test("an abort while agents archive keeps the workspace record active", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-agents");
|
||||
@@ -1089,16 +1090,12 @@ describe("archiveByScope cancellation", () => {
|
||||
await agentArchive.reached;
|
||||
controller.abort();
|
||||
agentArchive.open();
|
||||
const error = await canceledArchive(archive);
|
||||
await canceledArchive(archive);
|
||||
|
||||
expect({
|
||||
newlyArchivedAgentIds: error.newlyArchivedAgentIds,
|
||||
recordArchived: error.recordArchived,
|
||||
activeWorkspaceIds: (await deps.listActiveWorkspaces()).map((w) => w.workspaceId),
|
||||
directoryExists: existsSync(worktree.worktreePath),
|
||||
}).toEqual({
|
||||
newlyArchivedAgentIds: ["agent-active"],
|
||||
recordArchived: false,
|
||||
activeWorkspaceIds: [workspaceId],
|
||||
directoryExists: true,
|
||||
});
|
||||
@@ -1132,13 +1129,12 @@ describe("archiveByScope cancellation", () => {
|
||||
await recordArchive.reached;
|
||||
controller.abort();
|
||||
recordArchive.open();
|
||||
const error = await canceledArchive(archive);
|
||||
await canceledArchive(archive);
|
||||
|
||||
expect({
|
||||
recordArchived: error.recordArchived,
|
||||
activeWorkspaceIds: (await deps.listActiveWorkspaces()).map((w) => w.workspaceId),
|
||||
directoryExists: existsSync(worktree.worktreePath),
|
||||
}).toEqual({ recordArchived: true, activeWorkspaceIds: [], directoryExists: true });
|
||||
}).toEqual({ activeWorkspaceIds: [], directoryExists: true });
|
||||
});
|
||||
|
||||
test("an archive aborted before it starts touches nothing", async () => {
|
||||
@@ -1154,7 +1150,7 @@ describe("archiveByScope cancellation", () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const error = await canceledArchive(
|
||||
await canceledArchive(
|
||||
archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
requestId: "req-cancel-before",
|
||||
@@ -1162,63 +1158,10 @@ describe("archiveByScope cancellation", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(error.newlyArchivedAgentIds).toEqual([]);
|
||||
expect(deps.archivedSnapshotIds).toEqual([]);
|
||||
expect(deps.markWorkspaceArchiving).not.toHaveBeenCalled();
|
||||
expect(existsSync(worktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("a completed archive reports the agents it newly archived", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "newly-archived");
|
||||
const workspaceId = "ws-newly-archived";
|
||||
const deps = createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
|
||||
});
|
||||
deps.agentStorage = storedAgents(workspaceId, [
|
||||
{ id: "agent-active", archivedAt: null },
|
||||
{ id: "agent-already-archived", archivedAt: "2026-01-01T00:00:00.000Z" },
|
||||
]);
|
||||
|
||||
const result = await archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
requestId: "req-newly-archived",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.archivedAgentIds.sort()).toEqual(["agent-active", "agent-already-archived"]);
|
||||
expect(result.newlyArchivedAgentIds).toEqual(["agent-active"]);
|
||||
});
|
||||
|
||||
test("an archived agent loaded to read its history is not reported as newly archived", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "loaded-archived");
|
||||
const workspaceId = "ws-loaded-archived";
|
||||
const deps = createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
|
||||
});
|
||||
deps.agentStorage = storedAgents(workspaceId, [
|
||||
{ id: "agent-active", archivedAt: null },
|
||||
{ id: "agent-history", archivedAt: "2026-01-01T00:00:00.000Z" },
|
||||
]);
|
||||
deps.agentManager.listAgents = () =>
|
||||
[
|
||||
{ id: "agent-active", workspaceId },
|
||||
{ id: "agent-history", workspaceId },
|
||||
] as ManagedAgent[];
|
||||
|
||||
const result = await archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
requestId: "req-loaded-archived",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.newlyArchivedAgentIds).toEqual(["agent-active"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceIdAtPath", () => {
|
||||
@@ -1255,3 +1198,95 @@ describe("resolveWorkspaceIdAtPath", () => {
|
||||
expect(result).toBe("ws-nested");
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the operation timestamp for live agents, snapshots, and the workspace", async () => {
|
||||
const { repoDir } = createGitRepo();
|
||||
const deps = createArchiveDeps({
|
||||
paseoHome: repoDir,
|
||||
activeWorkspaces: [{ workspaceId: "stamped", cwd: repoDir, kind: "local_checkout" }],
|
||||
});
|
||||
const stamps: Array<string | undefined> = [];
|
||||
deps.agentManager.listAgents = () => [{ id: "live", workspaceId: "stamped" }] as ManagedAgent[];
|
||||
deps.agentManager.getAgent = () => ({ id: "live" }) as ManagedAgent;
|
||||
deps.agentManager.archiveAgent = async (_id, stamp) => {
|
||||
stamps.push(stamp);
|
||||
return { archivedAt: stamp! };
|
||||
};
|
||||
deps.agentStorage.listByWorkspace = async () =>
|
||||
[
|
||||
{ id: "stored", workspaceId: "stamped", archivedAt: null },
|
||||
{ id: "closed", workspaceId: "stamped", archivedAt: "2020-01-01T00:00:00.000Z" },
|
||||
] as StoredAgentRecord[];
|
||||
deps.agentManager.getAgent = (id) => (id === "live" ? ({ id } as ManagedAgent) : null);
|
||||
deps.agentManager.archiveSnapshot = async (_id, stamp) => {
|
||||
stamps.push(stamp);
|
||||
return {} as StoredAgentRecord;
|
||||
};
|
||||
deps.archiveWorkspaceRecord = async (_id, stamp) => {
|
||||
stamps.push(stamp);
|
||||
};
|
||||
await archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId: "stamped" },
|
||||
requestId: "stamp",
|
||||
});
|
||||
expect(stamps).toHaveLength(3);
|
||||
expect(typeof stamps[0]).toBe("string");
|
||||
expect(new Set(stamps).size).toBe(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
stamp: "2026-11-09T10:00:00.000Z",
|
||||
agents: [
|
||||
["a", "2026-11-09T10:00:00.000Z"],
|
||||
["b", "2026-11-09T10:00:00.000Z"],
|
||||
["closed", "2026-11-09T09:59:50.000Z"],
|
||||
["rearchived", "2026-11-09T10:00:00.001Z"],
|
||||
],
|
||||
expected: ["a", "b"],
|
||||
},
|
||||
{
|
||||
stamp: "2026-11-09T10:00:00.000Z",
|
||||
agents: [
|
||||
["a", "2026-11-09T09:59:00.000Z"],
|
||||
["b", "2026-11-09T09:59:59.000Z"],
|
||||
["closed", "2026-11-09T09:58:59.999Z"],
|
||||
["later", "2026-11-09T10:00:00.001Z"],
|
||||
],
|
||||
expected: ["a", "b"],
|
||||
},
|
||||
])(
|
||||
"restores only agents belonging to the workspace archive ($expected)",
|
||||
async ({ stamp, agents, expected }) => {
|
||||
const restored: string[] = [];
|
||||
await unarchiveWorkspaceAgents({
|
||||
workspaceId: "workspace",
|
||||
archivedAt: stamp,
|
||||
agentStorage: {
|
||||
listByWorkspace: async () =>
|
||||
agents.map(([id, archivedAt]) => ({ id, archivedAt })) as StoredAgentRecord[],
|
||||
},
|
||||
unarchiveAgent: async (id) => {
|
||||
restored.push(id);
|
||||
},
|
||||
});
|
||||
expect(restored).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
test("a canceled archive with no matching agents leaves earlier closed agents archived", async () => {
|
||||
const restored: string[] = [];
|
||||
await unarchiveWorkspaceAgents({
|
||||
workspaceId: "workspace",
|
||||
archivedAt: "2026-11-09T10:00:00.000Z",
|
||||
allowLegacyFallback: false,
|
||||
agentStorage: {
|
||||
listByWorkspace: async () =>
|
||||
[{ id: "closed", archivedAt: "2026-11-09T09:59:59.000Z" }] as StoredAgentRecord[],
|
||||
},
|
||||
unarchiveAgent: async (id) => {
|
||||
restored.push(id);
|
||||
},
|
||||
});
|
||||
expect(restored).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface ArchiveDependencies {
|
||||
// break a same-cwd tie in favor of the worktree-kind record when archiving by
|
||||
// path (no explicit workspaceId).
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
archiveWorkspaceRecord: (workspaceId: string, archivedAt: string) => Promise<void>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
||||
@@ -67,9 +67,6 @@ export type ArchiveScope =
|
||||
|
||||
export interface ArchiveResult {
|
||||
archivedAgentIds: string[];
|
||||
// Agents this request moved from active to archived. `archivedAgentIds` also lists agents that
|
||||
// were already archived; undo must restore only the ones this archive took down.
|
||||
newlyArchivedAgentIds: string[];
|
||||
archivedWorkspaceIds: string[];
|
||||
removedDirectory: boolean;
|
||||
// Set when the record archived but its worktree directory was left on
|
||||
@@ -86,16 +83,9 @@ export interface ArchiveByScopeRequest {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* The archive stopped at a checkpoint because its signal aborted. Whatever ran before the
|
||||
* checkpoint stays done; `newlyArchivedAgentIds` and `recordArchived` say how far it got so the
|
||||
* canceller can put it back.
|
||||
*/
|
||||
// Cancellation carries the operation stamp even before the workspace record is archived.
|
||||
export class WorkspaceArchiveCanceledError extends Error {
|
||||
constructor(
|
||||
public readonly newlyArchivedAgentIds: string[],
|
||||
public readonly recordArchived: boolean,
|
||||
) {
|
||||
constructor(public readonly archivedAt: string) {
|
||||
super("Workspace archive was canceled");
|
||||
this.name = "WorkspaceArchiveCanceledError";
|
||||
}
|
||||
@@ -165,13 +155,10 @@ async function archiveByScopeWithPriority(
|
||||
dependencies: ArchiveDependencies,
|
||||
request: ArchiveByScopeRequest,
|
||||
): Promise<ArchiveResult> {
|
||||
const progress: ArchiveProgress = { newlyArchivedAgentIds: new Set(), recordArchived: false };
|
||||
const archivedAt = new Date().toISOString();
|
||||
const checkpoint = () => {
|
||||
if (request.signal?.aborted) {
|
||||
throw new WorkspaceArchiveCanceledError(
|
||||
Array.from(progress.newlyArchivedAgentIds),
|
||||
progress.recordArchived,
|
||||
);
|
||||
throw new WorkspaceArchiveCanceledError(archivedAt);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -198,7 +185,7 @@ async function archiveByScopeWithPriority(
|
||||
const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords(
|
||||
dependencies,
|
||||
targetWorkspaceIds,
|
||||
{ requestId: request.requestId, checkpoint, progress },
|
||||
{ requestId: request.requestId, checkpoint, archivedAt },
|
||||
);
|
||||
checkpoint();
|
||||
|
||||
@@ -229,7 +216,6 @@ async function archiveByScopeWithPriority(
|
||||
|
||||
return {
|
||||
archivedAgentIds: Array.from(archivedAgents),
|
||||
newlyArchivedAgentIds: Array.from(progress.newlyArchivedAgentIds),
|
||||
archivedWorkspaceIds,
|
||||
removedDirectory,
|
||||
...(directoryLeftBehind ? { directoryLeftBehind } : {}),
|
||||
@@ -242,11 +228,6 @@ async function archiveByScopeWithPriority(
|
||||
}
|
||||
}
|
||||
|
||||
interface ArchiveProgress {
|
||||
newlyArchivedAgentIds: Set<string>;
|
||||
recordArchived: boolean;
|
||||
}
|
||||
|
||||
async function resolveArchiveTarget(
|
||||
dependencies: ArchiveDependencies,
|
||||
scope: ArchiveScope,
|
||||
@@ -393,23 +374,27 @@ async function resolveBackingDirectory(
|
||||
async function archiveTargetRecords(
|
||||
dependencies: ArchiveDependencies,
|
||||
targetWorkspaceIds: string[],
|
||||
options: { requestId: string; checkpoint: () => void; progress: ArchiveProgress },
|
||||
options: {
|
||||
requestId: string;
|
||||
checkpoint: () => void;
|
||||
archivedAt: string;
|
||||
},
|
||||
): Promise<{ archivedAgents: Set<string>; archivedWorkspaceIds: string[] }> {
|
||||
const archivedAgents = new Set<string>();
|
||||
const archivedWorkspaceIds: string[] = [];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
targetWorkspaceIds.map(async (workspaceId) => {
|
||||
const contents = await archiveWorkspaceContentsWithProgress(dependencies, workspaceId);
|
||||
for (const agentId of contents.newlyArchivedAgentIds) {
|
||||
options.progress.newlyArchivedAgentIds.add(agentId);
|
||||
}
|
||||
const contents = await archiveWorkspaceContents(
|
||||
dependencies,
|
||||
workspaceId,
|
||||
options.archivedAt,
|
||||
);
|
||||
// Agents and terminals are down, the record is not: an abort here leaves the workspace
|
||||
// listed, and the canceller restores the agents it reports.
|
||||
// listed, and cancellation restores agents carrying this operation stamp.
|
||||
options.checkpoint();
|
||||
await dependencies.archiveWorkspaceRecord(workspaceId);
|
||||
options.progress.recordArchived = true;
|
||||
return { workspaceId, agents: contents.agentIds };
|
||||
await dependencies.archiveWorkspaceRecord(workspaceId, options.archivedAt);
|
||||
return { workspaceId, agents: contents };
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -420,10 +405,7 @@ async function archiveTargetRecords(
|
||||
archivedAgents.add(agentId);
|
||||
}
|
||||
} else if (result.reason instanceof WorkspaceArchiveCanceledError) {
|
||||
throw new WorkspaceArchiveCanceledError(
|
||||
Array.from(options.progress.newlyArchivedAgentIds),
|
||||
options.progress.recordArchived,
|
||||
);
|
||||
throw result.reason;
|
||||
} else {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: result.reason, requestId: options.requestId },
|
||||
@@ -574,14 +556,8 @@ export type ArchiveWorkspaceContentsDependencies = Pick<
|
||||
export async function archiveWorkspaceContents(
|
||||
dependencies: ArchiveWorkspaceContentsDependencies,
|
||||
workspaceId: string,
|
||||
archivedAt = new Date().toISOString(),
|
||||
): Promise<Set<string>> {
|
||||
return (await archiveWorkspaceContentsWithProgress(dependencies, workspaceId)).agentIds;
|
||||
}
|
||||
|
||||
async function archiveWorkspaceContentsWithProgress(
|
||||
dependencies: ArchiveWorkspaceContentsDependencies,
|
||||
workspaceId: string,
|
||||
): Promise<{ agentIds: Set<string>; newlyArchivedAgentIds: string[] }> {
|
||||
const archivedAgents = new Set<string>();
|
||||
|
||||
const liveAgents = dependencies.agentManager
|
||||
@@ -605,34 +581,23 @@ async function archiveWorkspaceContentsWithProgress(
|
||||
archivedAgents.add(record.id);
|
||||
}
|
||||
|
||||
const archivedAt = new Date().toISOString();
|
||||
const agentIdsToArchive = [
|
||||
...new Set([
|
||||
...liveAgents.map((agent) => agent.id),
|
||||
...matchingStoredRecords.filter((record) => !record.archivedAt).map((record) => record.id),
|
||||
]),
|
||||
];
|
||||
// Undo brings back only what this archive took down. Internal agents are retired rather than
|
||||
// archived, and an agent loaded only to read its history was archived before this request.
|
||||
const restorableAgentIds = new Set(agentIdsToArchive);
|
||||
for (const agent of liveAgents) {
|
||||
if (agent.internal) restorableAgentIds.delete(agent.id);
|
||||
}
|
||||
for (const record of matchingStoredRecords) {
|
||||
if (record.archivedAt) restorableAgentIds.delete(record.id);
|
||||
}
|
||||
const [agentResults, [terminalResult]] = await Promise.all([
|
||||
Promise.allSettled(
|
||||
agentIdsToArchive.map((agentId) =>
|
||||
dependencies.agentManager.getAgent(agentId)
|
||||
? dependencies.agentManager.archiveAgent(agentId)
|
||||
? dependencies.agentManager.archiveAgent(agentId, archivedAt)
|
||||
: dependencies.agentManager.archiveSnapshot(agentId, archivedAt),
|
||||
),
|
||||
),
|
||||
Promise.allSettled([dependencies.killTerminalsForWorkspace(workspaceId)]),
|
||||
]);
|
||||
|
||||
const newlyArchivedAgentIds: string[] = [];
|
||||
for (const [index, result] of agentResults.entries()) {
|
||||
const agentId = agentIdsToArchive[index]!;
|
||||
if (result.status === "rejected") {
|
||||
@@ -640,8 +605,6 @@ async function archiveWorkspaceContentsWithProgress(
|
||||
{ err: result.reason, workspaceId, agentId },
|
||||
"Workspace archive teardown step failed; continuing",
|
||||
);
|
||||
} else if (restorableAgentIds.has(agentId)) {
|
||||
newlyArchivedAgentIds.push(agentId);
|
||||
}
|
||||
}
|
||||
if (terminalResult?.status === "rejected") {
|
||||
@@ -651,7 +614,7 @@ async function archiveWorkspaceContentsWithProgress(
|
||||
);
|
||||
}
|
||||
|
||||
return { agentIds: archivedAgents, newlyArchivedAgentIds };
|
||||
return archivedAgents;
|
||||
}
|
||||
|
||||
// True when, after archiving
|
||||
@@ -748,3 +711,43 @@ export async function archivePersistedWorkspaceRecord(input: {
|
||||
|
||||
return existingWorkspace;
|
||||
}
|
||||
|
||||
export interface WorkspaceArchiveRestoreRequest {
|
||||
workspaceId: string;
|
||||
archivedAt: string;
|
||||
allowLegacyFallback?: boolean;
|
||||
}
|
||||
|
||||
interface UnarchiveWorkspaceAgentsInput extends WorkspaceArchiveRestoreRequest {
|
||||
agentStorage: Pick<AgentStorage, "listByWorkspace">;
|
||||
unarchiveAgent: (agentId: string) => Promise<unknown>;
|
||||
sessionLogger?: Logger;
|
||||
}
|
||||
|
||||
export async function unarchiveWorkspaceAgents(
|
||||
input: UnarchiveWorkspaceAgentsInput,
|
||||
): Promise<void> {
|
||||
const { workspaceId, archivedAt, allowLegacyFallback = true } = input;
|
||||
const records = await input.agentStorage.listByWorkspace(workspaceId);
|
||||
const exact = records.filter((record) => record.archivedAt === archivedAt);
|
||||
// COMPAT(workspaceArchiveStamp): added in v0.15.3; remove after 2027-05-09 once old workspace archives no longer need restoration.
|
||||
const matching =
|
||||
exact.length > 0 || !allowLegacyFallback
|
||||
? exact
|
||||
: records.filter((record) => {
|
||||
if (!record.archivedAt) return false;
|
||||
const age = Date.parse(archivedAt) - Date.parse(record.archivedAt);
|
||||
return age >= 0 && age <= 60_000;
|
||||
});
|
||||
const results = await Promise.allSettled(
|
||||
matching.map((record) => input.unarchiveAgent(record.id)),
|
||||
);
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === "rejected") {
|
||||
input.sessionLogger?.warn(
|
||||
{ err: result.reason, workspaceId, agentId: matching[index]!.id },
|
||||
"Failed to unarchive workspace agent",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
@@ -273,3 +274,108 @@ test("an undo on a workspace that was never archived leaves it as it was", async
|
||||
agentArchived: await isAgentArchived(agentId),
|
||||
}).toEqual({ workspaceActive: true, directoryExists: true, agentArchived: false });
|
||||
}, 60000);
|
||||
|
||||
test.each(["restore", "undo", "reopen", "refresh", "legacy"] as const)(
|
||||
"%s after a daemon restart returns the archive's agents and leaves earlier closed agents archived",
|
||||
async (route) => {
|
||||
const artifacts = path.resolve(import.meta.dirname, "../../../../.artifacts");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
const tempRoot = mkdtempSync(path.join(artifacts, "archive-restart-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const repoDir = path.join(tempRoot, "repo");
|
||||
git(tempRoot, ["init", "-b", "main", repoDir]);
|
||||
git(repoDir, [
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"initial",
|
||||
]);
|
||||
await ctx.cleanup();
|
||||
ctx = await createDaemonTestContext({
|
||||
paseoHomeRoot: path.join(tempRoot, "home"),
|
||||
cleanup: false,
|
||||
});
|
||||
const created = await ctx.client.createWorkspace({
|
||||
source: { kind: "directory", path: repoDir },
|
||||
});
|
||||
const workspaceId = created.workspace!.id;
|
||||
const agents = [];
|
||||
for (const title of ["A", "B", "C"]) {
|
||||
agents.push(
|
||||
await ctx.client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd: repoDir,
|
||||
workspaceId,
|
||||
title,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const [a, b, c] = agents;
|
||||
if (!a || !b || !c) throw new Error("Expected three agents");
|
||||
await ctx.daemon.daemon.agentManager.archiveAgent(c.id, "2020-01-01T00:00:00.000Z");
|
||||
const archive = await ctx.client.archiveWorkspace(workspaceId);
|
||||
if (route === "legacy") {
|
||||
const stamp = new Date(Date.parse(archive.archivedAt!) - 1000).toISOString();
|
||||
await ctx.daemon.daemon.agentManager.archiveSnapshot(a.id, stamp);
|
||||
await ctx.daemon.daemon.agentManager.archiveSnapshot(b.id, stamp);
|
||||
}
|
||||
await ctx.cleanup();
|
||||
ctx = await createDaemonTestContext({
|
||||
paseoHomeRoot: path.join(tempRoot, "home"),
|
||||
cleanup: false,
|
||||
});
|
||||
expect(await isAgentArchived(a.id)).toBe(true);
|
||||
if (route === "restore" || route === "legacy") await ctx.client.restoreWorkspace(workspaceId);
|
||||
if (route === "undo") await ctx.client.undoWorkspaceArchive(workspaceId);
|
||||
if (route === "reopen") await ctx.client.openProject(repoDir);
|
||||
if (route === "refresh") {
|
||||
const legacy = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
appVersion: "0.1.104",
|
||||
});
|
||||
await legacy.connect();
|
||||
try {
|
||||
await legacy.refreshAgent(a.id);
|
||||
} finally {
|
||||
await legacy.close();
|
||||
}
|
||||
}
|
||||
expect(await isWorkspaceActive(workspaceId)).toBe(true);
|
||||
expect(await isAgentArchived(a.id)).toBe(false);
|
||||
expect(await isAgentArchived(b.id)).toBe(false);
|
||||
expect(await isAgentArchived(c.id)).toBe(true);
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"a failed worktree restore leaves every agent archived until retry succeeds",
|
||||
async () => {
|
||||
const repo = createRepoWithGatedTeardown();
|
||||
repo.releaseTeardown();
|
||||
const { workspaceId, agentId } = await createWorktreeWorkspaceWithAgent(
|
||||
repo.repoDir,
|
||||
"restore-retry",
|
||||
);
|
||||
await ctx.client.archiveWorkspace(workspaceId);
|
||||
const displaced = repo.repoDir + "-unavailable";
|
||||
renameSync(repo.repoDir, displaced);
|
||||
try {
|
||||
await expect(ctx.client.restoreWorkspace(workspaceId)).rejects.toThrow("source repository");
|
||||
expect(await isWorkspaceActive(workspaceId)).toBe(false);
|
||||
expect(await isAgentArchived(agentId)).toBe(true);
|
||||
} finally {
|
||||
renameSync(displaced, repo.repoDir);
|
||||
}
|
||||
await ctx.client.restoreWorkspace(workspaceId);
|
||||
expect(await isWorkspaceActive(workspaceId)).toBe(true);
|
||||
expect(await isAgentArchived(agentId)).toBe(false);
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
@@ -354,6 +354,7 @@ function createPaseoWorktreeForTest(options: {
|
||||
},
|
||||
};
|
||||
const workspaceProvisioning = createWorkspaceProvisioningService({
|
||||
unarchiveWorkspaceAgents: async () => {},
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
|
||||
@@ -9,6 +9,12 @@ async function openPlugins(page: Page) {
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
|
||||
}
|
||||
|
||||
async function waitForInteractivePage(page: Page) {
|
||||
// Server-rendered content is visible before hydration attaches its click handlers.
|
||||
// The language selector stays disabled until that page has hydrated.
|
||||
await expect(page.getByRole("button", { name: "Language: English" }).first()).toBeEnabled();
|
||||
}
|
||||
|
||||
function pluginCards(page: Page, section: string): Locator {
|
||||
return page
|
||||
.getByRole("region", { name: section })
|
||||
@@ -78,15 +84,18 @@ test("browses from the directory into a category, a plugin, and its author", asy
|
||||
});
|
||||
});
|
||||
await openPlugins(page);
|
||||
await waitForInteractivePage(page);
|
||||
|
||||
await page.getByRole("region", { name: "Categories" }).getByRole("link", { name: /Git/ }).click();
|
||||
await expect(page).toHaveURL(/\/plugins\/category\/git$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible();
|
||||
await waitForInteractivePage(page);
|
||||
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("link", { name: /Fresh Worktrees/ }).click();
|
||||
await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/);
|
||||
await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1);
|
||||
await waitForInteractivePage(page);
|
||||
await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.15.4",
|
||||
"version": "0.16.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user