Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] fff6d23dbe test(app): wait for active agent controls and submitted turns
Observe the mode control before testing Command Center keyboard selection. Observe a running turn before waiting for its completion, so the rewind test cannot mistake the previous idle state and end marker for the new turn.
2026-10-22 10:21:41 +02:00
paseo-bot[bot]andJosh Lane 7439729e50 fix(claude): keep large sessions importable while streaming history
Preserve import metadata for transcripts containing image records above 4 MiB. Stream complete records instead of rejecting otherwise listable sessions, and cover overlapping requests across aliased provider roots under a constrained heap.

Co-authored-by: Josh Lane <joshlane@easypost.com>
2026-10-22 09:48:34 +02:00
paseo-bot[bot] 3c05dcbf9a Merge commit '21f207d81d7e81d65f88dc1f8cb17bbb5db61339' into bot/diagnose-6669-e55f603b 2026-10-22 09:42:54 +02:00
Mohamed Boudra 4574142314 fix(server): use only the configured models for manual metadata generation (#7611)
Manual metadata generation is a non-empty agents.metadataGeneration.providers
list, but the resolver always appended the built-in defaults and the current
agent selection after it. When a manual model failed, the prompt was retried
on providers the user never chose. Manual now resolves to exactly the
configured list; Automatic is unchanged.

Commit message and PR text generation swallowed failures without logging, so
a broken manual model was invisible. The git metadata generator now logs the
final failure with each attempt's provider, model and error, and the
structured generation loop receives the session logger for per-attempt
warnings, matching the branch name path.
2026-10-22 14:36:22 +07:00
paseo-bot[bot]and3ae3ae 4673ab1903 fix(app): fall back to relay when the direct connection drops (#7616)
The probe cycle reused the selected client's last heartbeat RTT even
after that client disconnected. A dead direct connection kept reporting
as available, so the app never switched to a reachable relay until it
was restarted. Mark the selected connection unavailable while its client
is not connected and let the existing failover pick another connection.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
2026-10-22 09:08:33 +02:00
Mohamed Boudra 76b32544c6 Give Markdown and HTML file tabs back their Preview and Source views (#7614)
* fix(app): give Markdown and HTML file tabs back their Preview and Source views

The built-in Markdown and HTML panels own one toolbar again: file size,
line count, save status, vim mode, cursor, and a Preview | Source control
over a body that shows the rendered preview or the editor from the same
loaded content and editor model. Plain text files keep the toolbar on top.

The host no longer renders a panel switcher. Source is the built-in panel
for text files no other built-in claims, so it is never offered beside
Markdown or HTML in the picker, the Open with menu, or Settings > Files.
A line target opens the file's built-in panel in its Source view.

* refactor(app): name the shared text file panel TextFilePanel

Markdown and HTML own a Source view; they are not Source panels. The
composite in file-panels/source/panel.tsx is now TextFilePanel, and
SourcePanel is TextFilePanel without a Preview. Names only.
2026-10-22 13:04:59 +07:00
Mohamed Boudra bdae614e95 fix(app): restore chats after reopening unread workspaces (#7608)
Parked pane focus was persisted without its in-memory restoration record, so cold starts exposed hidden Explorer tabs and never requested the agent timeline. Restore an ordinary pane at the layout boundary, including for existing saved layouts.
2026-10-22 11:45:31 +07:00
Mohamed Boudra bad132884d Translate the 0.13.0-beta.1 changelog and render changelog code blocks (#7606)
* fix(website): render changelog code blocks as blocks

* docs(changelog): translate 0.13.0-beta.1 and write translations with each release
2026-10-22 10:15:43 +07:00
paseo-ai[bot] 68fa464cdf fix: update lockfile signatures and Nix hash [skip ci] 2026-10-22 02:04:09 +00:00
Mohamed Boudra 3fa625701e chore(release): cut 0.13.0-beta.1 2026-10-22 03:53:53 +02:00
Mohamed Boudra 5d2a424868 docs(changelog): prepare 0.13.0-beta.1 2026-10-22 03:48:56 +02:00
Mohamed Boudra 1198cddf63 Document cross-host agent workflows in the Paseo skill (#7593)
* docs(skills): explain cross-host agent workflows

* docs(skills): shorten subagent waiting guidance
2026-10-22 07:53:47 +07:00
ce67efa9e7 Keep Claude request context out of restored chats (#7590)
* fix(server): skip Claude request-journal records when loading history

Claude Code 2.1.304+ journals each API request into the transcript as
api-request-blob records. They carry a message with a role and content,
so the history loader rendered them as assistant messages: restored
timelines gained the session's system context, including SessionStart
hook output and skill and tool listings.

Only user and assistant records are conversation turns once the system
record handling has run, so short-circuit every other type.

Closes #7102

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(claude): keep request context out of restored chats

Claude request-journal records contain message-shaped tool context. Only conversation records should enter the ordinary history mapper, after compaction and background task notifications are handled.

Co-authored-by: Kheva Mann <khevamann@users.noreply.github.com>
Co-authored-by: rogerluan <rogerluan@users.noreply.github.com>

---------

Co-authored-by: Roger Oba <rogerluan.oba@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Kheva Mann <khevamann@users.noreply.github.com>
Co-authored-by: rogerluan <rogerluan@users.noreply.github.com>
2026-10-21 18:54:11 +02:00
Mohamed Boudra e522e6ea8e fix(app): make the compact Explorer tab rail usable on phones (#7589)
Popovers on Android landed one status bar below their trigger. React Native
0.86 stopped subtracting the status bar from window coordinates under
edge-to-edge, so measureInWindow, touch pageY and a full-screen Modal already
share screen-top coordinates; the old StatusBar.currentHeight compensation in
the menu engine, tooltip, context menu and sidebar long-press now
double-shifted every anchor.

Compact tabs keep their natural width and the rail scrolls as soon as they
overflow, instead of squeezing labels first and then scrolling anyway. They
drop the inline close because the long-press sheet already closes a tab and a
narrow X beside the label is easy to hit. Any scrolling rail keeps natural
widths rather than collapsing every chip to the minimum, and the rail resets
its offset when scrolling ends so tabs scrolled out of view come back.

The Explorer rail paints the sidebar surface, so its chips climb the sidebar
hover and selected tokens. Light themes paint the sidebar with surface2, which
made the surface2 active chip invisible there.

The compact Explorer closes with the same X as the compact agent list, and the
compact + menu omits Diff because compact Changes already shows diffs inline.
2026-10-21 21:44:53 +07:00
1e0a9d5af0 fix(plugins): allow notification timeline transformers (#7582)
Co-authored-by: 陈睿 <chenrui.92@dcarlife.com>
Co-authored-by: Gavin Chen <chenrui920614@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-21 14:57:22 +02:00
Mohamed Boudra 995ad2f878 feat(plugin): render Markdown content with host UI (#7579)
* feat(plugin): expose Markdown content component

* docs(plugin): translate Markdown component reference
2026-10-21 19:14:17 +07:00
Mohamed Boudra 1d383519dd feat(app): always show the host picker with Add host on New workspace (#7580)
The Host field used to render only with two or more hosts, and Add host
only appeared on compact layouts. Single-host users had no sign that
Paseo can drive other machines from this screen. The field now always
renders and its menu always ends with Add host.
2026-10-21 18:56:12 +07:00
Mohamed Boudra 23c4df4818 Fix agents showing running after daemon restart (#7573)
* fix(server): close stale agent status after daemon restart

Repair persisted active statuses before serving clients. Let Claude process-tree cleanup run immediately after SDK close rather than waiting on an already closed control channel.

* test(app): expect closed agent status after daemon restart
2026-10-21 18:28:31 +07:00
Mohamed Boudra c63e1204b0 Review all branch changes by default without automatic scope switching (#7576)
* feat(git): default review to all branch changes

Keep the selected comparison stable while agents edit and commit so reviewing the complete result does not require switching scopes or interrupt unfinished comments.

* test(git): release Windows checkout handles during cleanup

Allow transient filesystem handles from subscription teardown to close before removing the test repository.
2026-10-21 18:28:23 +07:00
Mohamed Boudra d0568f9f1b Batch agent reports and completion notifications (#7561)
* fix(server): batch agent reports and completion notifications

Admit agent-originated messages immediately so a child can report and finish before one recipient-owned delivery. Keep human prompts direct, retain individual provenance through replay, and complete send receipts only after provider dispatch.

* test(server): wire queued delivery into bridge integration fixture

* test(server): resolve batching CLI fixture independently of cwd

Workspace CI runs from packages/server, while the original regression was verified from the repository root. Resolve the CLI entrypoint relative to this test file in both contexts.

* test(server): run batching CLI fixture with source exports

Server integration CI builds only the client dependency stack. Match the existing CLI subprocess fixtures so the send regression exercises workspace sources without requiring compiled server output.

* test(server): await persisted batch receipts within integration deadline

The provider-start callback precedes asynchronous receipt commits. Use the same explicit integration deadline as dispatch instead of the matcher default of one second on Windows CI.
2026-10-21 18:28:03 +07:00
Josh Lane 21f207d81d Merge branch 'main' into pr-stream-cache-import-descriptors 2026-10-15 12:41:14 -07:00
Josh Lane afd338d65e fix(claude): surface session import read failures
Skip expected candidate read failures and return healthy sessions.
Distinguish unreadable metadata from parsed transcripts, and raise
AggregateError when every candidate is unreadable.

Propagate systemic file errors and narrow discovery error suppression.

Detected-By: user
2026-10-06 10:01:06 -04:00
Josh Lane 568aaeaf93 fix(claude): parallelize resilient session scans
Use up to four workers per listing with a shared four-read limit
and bounded FIFO queuing. Preserve cache-key deduplication,
subscriber cancellation, and candidate order.

Log and skip oversized or unreadable transcripts so healthy
sessions remain visible. Propagate programming and abort errors.

Detected-By: user
2026-10-06 09:26:34 -04:00
Josh Lane c4a8efddb1 fix(claude): bound and schedule import reads
Reject JSONL records over 4 MiB explicitly to preserve metadata.
Limit shared reads to four globally and one per listing for fairness.
Deduplicate reads by cacheKey with independent subscriber cancellation.
Set a 45-second provider deadline before the 60-second client timeout.
Clone returned descriptors to isolate overlapping callers.

Validation: 97 focused tests passed; independent review approved.

Detected-By: user
2026-10-06 09:08:19 -04:00
Josh Lane 9b61a6779d fix(claude): stream and cache session import descriptors 2026-10-02 14:10:20 -07:00
167 changed files with 5196 additions and 1740 deletions
+65
View File
@@ -1,5 +1,70 @@
# Changelog
## 0.13.0-beta.1 - 2026-10-22
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
```sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
```
### Added
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
- Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576))
- Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453))
### Improved
- Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580))
- Replaced the file tab's Preview/Source toggle with a switcher between the file's panels ([#7411](https://github.com/getpaseo/paseo/pull/7411))
- Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
- Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
- Paused status ring animations inside hidden panels on iOS and Android ([#7423](https://github.com/getpaseo/paseo/pull/7423) by [@peterkeen](https://github.com/peterkeen))
- Reduced app startup work by loading only the selected language's translations ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8))
- Removed repeated workspace layout saves when nothing changed ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8))
### Plugins
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
- Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478))
- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579))
- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368))
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474))
- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
### Fixed
- Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573))
- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
- Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538))
- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362))
- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365))
- Fixed `paseo agent archive` reporting `AGENT_NOT_FOUND` for agents beyond the first 200 ([#7535](https://github.com/getpaseo/paseo/pull/7535) by [@L4XB](https://github.com/L4XB), [@hteo1337](https://github.com/hteo1337))
- Fixed archived agents reappearing in MCP `list_agents` after their history was read ([#7532](https://github.com/getpaseo/paseo/pull/7532) by [@thomasvan](https://github.com/thomasvan))
- Fixed detached agents created with `notifyOnFinish` never notifying their creator ([#7528](https://github.com/getpaseo/paseo/pull/7528) by [@thomasvan](https://github.com/thomasvan))
- Fixed OpenCode agents being told `notifyOnFinish` defaults to off ([#7383](https://github.com/getpaseo/paseo/pull/7383) by [@gszep](https://github.com/gszep))
- Fixed Pi subagent rows staying running when a resume is refused ([#7369](https://github.com/getpaseo/paseo/pull/7369))
- Fixed Android source builds downloading a second NDK ([#7403](https://github.com/getpaseo/paseo/pull/7403) by [@dwyanewang](https://github.com/dwyanewang))
- Fixed Nix packages reporting the AGPL license instead of Apache-2.0 ([#7415](https://github.com/getpaseo/paseo/pull/7415))
## 0.12.0 - 2026-10-19
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
+65
View File
@@ -1,5 +1,70 @@
# 更新日志
## 0.13.0-beta.1 - 2026-10-22
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
将你的 daemon 连接到另一台机器,然后让 Agent 把工作委派过去:
```sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
```
### 新增
- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- 新增已连接主机,Agent 可以在其他机器上创建、提示和监控 Agent,并在 Subagents 中查看它们 ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
- 新增简体中文版 paseo.sh,地址为 [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Changes 窗格新增 All changes 并设为默认,在一个 diff 中显示自分支基点以来已提交和未提交的工作 ([#7576](https://github.com/getpaseo/paseo/pull/7576))
- 如果进入 Plan 前开启了 Auto,Claude 计划审批新增 Implement with Auto mode ([#7453](https://github.com/getpaseo/paseo/pull/7453))
### 改进
- 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580))
- 文件标签页的 Preview/Source 切换改为在该文件的各个面板之间切换 ([#7411](https://github.com/getpaseo/paseo/pull/7411))
- Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
- 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
- iOS 和 Android 上隐藏面板中的状态环动画现在会暂停 ([#7423](https://github.com/getpaseo/paseo/pull/7423) by [@peterkeen](https://github.com/peterkeen))
- 应用启动时只加载所选语言的翻译,减少了启动工作量 ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8))
- 工作区布局未变化时不再重复保存 ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8))
### 插件
- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
- 新增[插件翻译](https://paseo.sh/zh-CN/docs/plugins/reference#runtime-translations),插件内容和标签会跟随应用的语言 ([#7478](https://github.com/getpaseo/paseo/pull/7478))
- 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579))
- 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368))
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474))
- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
### 修复
- 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573))
- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547))
- 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538))
- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362))
- 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365))
- 修复了 `paseo agent archive` 对前 200 个之后的 Agent 报告 `AGENT_NOT_FOUND` 的问题 ([#7535](https://github.com/getpaseo/paseo/pull/7535) by [@L4XB](https://github.com/L4XB), [@hteo1337](https://github.com/hteo1337))
- 修复了读取历史记录后已归档的 Agent 重新出现在 MCP `list_agents` 中的问题 ([#7532](https://github.com/getpaseo/paseo/pull/7532) by [@thomasvan](https://github.com/thomasvan))
- 修复了使用 `notifyOnFinish` 创建的分离 Agent 从不通知其创建者的问题 ([#7528](https://github.com/getpaseo/paseo/pull/7528) by [@thomasvan](https://github.com/thomasvan))
- 修复了 OpenCode Agent 被告知 `notifyOnFinish` 默认关闭的问题 ([#7383](https://github.com/getpaseo/paseo/pull/7383) by [@gszep](https://github.com/gszep))
- 修复了恢复被拒绝时 Pi 子 Agent 行仍显示为运行中的问题 ([#7369](https://github.com/getpaseo/paseo/pull/7369))
- 修复了 Android 源码构建下载第二个 NDK 的问题 ([#7403](https://github.com/getpaseo/paseo/pull/7403) by [@dwyanewang](https://github.com/dwyanewang))
- 修复了 Nix 包将许可证报告为 AGPL 而不是 Apache-2.0 的问题 ([#7415](https://github.com/getpaseo/paseo/pull/7415))
## 0.12.0 - 2026-10-19
后台工作区、新的 Markdown 渲染器(手机上可跨段落选择文本)、重新设计的手机布局,以及超过 120 项修复。
+6 -1
View File
@@ -75,7 +75,12 @@ Setup belongs to the workspace, including combined workspace-plus-agent creation
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` for explicit reporting or no completion callback.
Agent-originated messages and automatic notifications share a recipient-daemon delivery module. A queued receipt acknowledges admission before provider dispatch, allowing a child to report and finish in the same two-second quiet window. Human prompts dispatch directly. Conflicting explicit interruption or session-mode choices retain separate ordered dispatches.
The pending window is in memory. Clean shutdown flushes it before closing agents; an abrupt crash can lose it. A durable send receipt remains pending until actual dispatch acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued admission within their original timeout and cancellation scope.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
+1 -3
View File
@@ -317,7 +317,7 @@ Paseo tools; otherwise the provider policy applies. Provider and global policy a
session is created, resumed, imported, or reloaded, so configuration changes affect the next
session rather than an already-running one.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
`agents.metadataGeneration.providers` selects the models for daemon-side metadata tasks: commit messages, PR text, branch names, and generated titles. An empty list is Automatic; a non-empty list is Manual and only those models are tried. See [public-docs/metadata-generation.md](../public-docs/metadata-generation.md#how-a-model-is-chosen).
### Git process limits
@@ -353,8 +353,6 @@ Environment variables override `config.json`:
after changing `config.json`. Environment changes require a daemon restart; the launch environment
remains authoritative during reload.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
Set these to select OpenAI instead of local speech:
+6 -1
View File
@@ -83,7 +83,12 @@ literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibili
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
hide their close buttons through the panel manifest; close them from the tab context menu.
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock, including Agent and terminal profiles.
Other tabs reveal the close control on hover. Compact layouts show no inline close on any tab,
since a narrow X beside the label is easy to hit by accident; the long-press menu closes it.
Compact tabs also keep their natural width and the rail scrolls as soon as they overflow, while
pointer layouts shrink tabs toward the clickable minimum first. A rail that scrolls never truncates.
The + menu opens compatible panels in the dock, including Agent and terminal profiles. Compact
layouts omit Diff from it because compact Changes shows each file's diff inline.
Web also supports dragging agents and terminals into Explorer.
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
+6 -4
View File
@@ -282,10 +282,12 @@ left = anchorRect.x - hostRect.x;
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
```
Do this before adding any platform offset. If anchor and host are both measured
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
Only add a status-bar offset when the render surface is not measured in the same
coordinate system. See `tooltip.tsx` for that separate case.
Do this before adding any platform offset. The app draws edge-to-edge on Android, so
`measureInWindow`, touch `pageY`, and a full-screen `Modal` all share one coordinate
space that starts at the screen top. Do not add `StatusBar.currentHeight` to an anchor:
React Native 0.86 stopped subtracting the status bar from window coordinates under
edge-to-edge, and the old compensation now lands every menu, tooltip, and context menu
one status bar below its trigger.
## Gotcha 5 — The two-measurement flash
+5 -3
View File
@@ -566,9 +566,11 @@ one section's order from built-ins, plugin groups, and the section's preference
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
panel, and mounts it with a `PluginFile` handle; the panel reads, shows its own `Loading`, writes,
and resolves conflicts. The bar only switches panels and shows the dot the panel sets through
`tab.setModified`. Built-in panels (Markdown, HTML, Image, and Source, Paseo's editor) take the
same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
and resolves conflicts. The host has no bar and no notion of a panel's views: the Markdown and HTML
panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the
dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in
claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's
editor) take the same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
make that panel faster instead. Source highlights a large file on native in idle-time slices,
the lines around the opening line first.
+4 -4
View File
@@ -538,8 +538,8 @@ Older prerelease bodies on GitHub keep the notes they were synced with; `Release
`CHANGELOG.zh-CN.md` holds the Chinese notes for `paseo.sh/zh-CN/changelog`. Only the website reads it; the app's **What's new** sheet, GitHub release bodies and F-Droid read `CHANGELOG.md`.
- Translations never gate a release. Translate a new entry in a follow-up PR, or in the changelog commit when you can.
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice, so a beta rewritten in place drops its old translation until the new notes are translated. Delete Chinese entries whose English entry was renamed.
- The release agent writes the Chinese entry in the changelog commit, after the English entry is approved. Every beta that rewrites the English entry rewrites the Chinese one too, and promotion renames both. `packages/website/e2e/i18n.spec.ts` fails CI on the release commit when any entry has no current translation.
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice. Delete Chinese entries whose English entry was renamed.
- Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
## Changelog ownership
@@ -654,7 +654,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
@@ -677,7 +677,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
+1 -1
View File
@@ -1 +1 @@
sha256-La6xekVRQ9F6UulA6OHX5SzaXv6L0qZ2KpGkRScFd8Q=
sha256-DkB70/NH4SriKZ6hMDhI7XfwkqjGfb9e1zC3dxjBaTg=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -35656,7 +35656,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37922,12 +37922,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/server": "0.12.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/server": "0.13.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38174,10 +38174,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -38189,7 +38189,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38433,7 +38433,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -42085,7 +42085,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -42114,15 +42114,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -42132,8 +42132,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -42155,7 +42155,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -42182,7 +42182,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -42400,16 +42400,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0",
"@getpaseo/highlight": "0.12.0",
"@getpaseo/plugin": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/highlight": "0.13.0-beta.1",
"@getpaseo/plugin": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -43330,7 +43330,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -44940,7 +44940,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -21,8 +21,10 @@ async function completeSubmittedTurn(
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
await expectAgentReadyToInterrupt(page);
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expectAgentIdle(page);
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
return userMessage;
+135 -5
View File
@@ -262,24 +262,74 @@ test.afterEach(async () => {
}
});
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
test("Changes opens All changes for a clean checkout", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace, 90_000);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("All changes");
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
});
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
test("All changes combines committed and pending edits and preserves an unfinished review", async ({
page,
context,
}) => {
const workspace = await createWorkspaceWithNetDiff();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
await openWorkspaceChangesSurface(page, workspace);
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const mode = tree.getByRole("button", { name: "Diff mode", exact: true });
await expect(mode).toContainText("All changes");
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts"]);
await expectRenderedAddedLine(page, "committed");
await test.step("an unfinished review survives the first dirty edit", async () => {
await startReviewOnFirstChangedLine(page);
await page
.getByRole("textbox", { name: "Review comment" })
.fill("Unfinished review stays here");
await writeFile(path.join(workspace.repoPath, "c-untracked.txt"), "untracked\n");
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]);
await expect(mode).toContainText("All changes");
await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(
"Unfinished review stays here",
);
await cancelInlineReview(page);
});
await test.step("pending edits form a live net patch over the committed edit", async () => {
await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "working\n");
await expectRenderedAddedLine(page, "working");
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("a-tracked.ts, +1, -1");
});
await test.step("committing the same contents preserves the rendered comparison", async () => {
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
execFileSync("git", ["commit", "-m", "Commit pending changes"], { cwd: workspace.repoPath });
await expect(page.getByTestId("changes-primary-cta")).toContainText("Merge", {
timeout: 30_000,
});
await expect(mode).toContainText("All changes");
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]);
await expectRenderedAddedLine(page, "working");
});
await test.step("undoing to the comparison base removes the net change", async () => {
await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "base\n");
await expectNetComparisonFiles(page, ["b-committed.ts", "c-untracked.txt"]);
await expect(mode).toContainText("All changes");
});
});
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const mode = tree.getByTestId("changes-diff-status-trigger");
await expect(mode).toContainText("Uncommitted");
await expect(mode).toContainText("All changes");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
@@ -294,7 +344,23 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
});
await writeFile(path.join(workspace.repoPath, "new-working-change.txt"), "uncommitted\n");
await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible({
timeout: 30_000,
});
await expect(mode).toContainText("Committed");
await selectChangesComparison(page, "Uncommitted");
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
execFileSync("git", ["commit", "-m", "Commit new working change"], { cwd: workspace.repoPath });
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(mode).toContainText("Uncommitted");
await writeFile(path.join(workspace.repoPath, "another-working-change.txt"), "pending\n");
await expect(tree.getByText("another-working-change.txt", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(mode).toContainText("Uncommitted");
});
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
@@ -633,6 +699,7 @@ test("changes context menus expose folder revert and restore a file after confir
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
const tree = changesTree(page);
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
@@ -680,6 +747,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
@@ -710,6 +778,7 @@ test("discarding an untracked file removes it from the working tree", async ({ p
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
@@ -741,6 +810,7 @@ test("shows a revert error returned by the daemon", async ({ page }) => {
await failNextDiscardRequest(page);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
@@ -892,6 +962,8 @@ test("compact Explorer renders inline hunks with a saved tree-only desktop prefe
{ key: CHANGES_PREFERENCES_KEY },
);
const explorer = await openCompactChanges(page, workspace);
await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible();
await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible();
await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" });
@@ -1525,6 +1597,64 @@ async function createWorkspaceWithMountedTabDiff(
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithNetDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-net-", {
files: [{ path: "a-tracked.ts", content: "base\n" }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close();
await repo.cleanup();
},
});
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
await writeFile(path.join(repo.path, "a-tracked.ts"), "committed\n");
await writeFile(path.join(repo.path, "b-committed.ts"), "committed only\n");
execFileSync("git", ["add", "--all"], { cwd: repo.path });
execFileSync("git", ["commit", "-m", "Committed changes"], { cwd: repo.path });
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create net diff workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function expectNetComparisonFiles(page: Page, files: string[]): Promise<void> {
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText(
files,
);
await expect(panel.getByTestId(/^diff-file-\d+$/)).toHaveCount(files.length);
for (const file of files) {
await expect(diffHeaderForPath(panel, file)).toBeVisible();
}
}
async function expectRenderedAddedLine(page: Page, contents: string): Promise<void> {
await changesTree(page).getByText("a-tracked.ts", { exact: true }).click();
await expect(async () => {
await page.keyboard.press("Escape");
await page.evaluate(() => navigator.clipboard.writeText(""));
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
const bounds = await body.boundingBox();
if (!bounds) throw new Error("Expanded diff body has no bounds");
const fontSize = await canvas.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
// Replacement: hunk header, removed line, added line.
await page.mouse.click(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5, {
button: "right",
});
const copyLine = page.getByRole("menuitem", { name: "Copy line", exact: true });
await expect(copyLine).toBeEnabled({ timeout: 1000 });
await copyLine.click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 1000 })
.toBe(contents);
}).toPass({ timeout: 15_000 });
}
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-committed-", {
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
@@ -182,7 +182,8 @@ test.describe("Workspace file change conflicts", () => {
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await unlink(filePath);
await expect(filePane(page).getByText("The file was deleted.", { exact: true })).toBeVisible();
await expectDeletedFileNotice(page);
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await writeFile(filePath, "# After\n", "utf8");
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
+52 -17
View File
@@ -1,5 +1,6 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import {
expandFolder,
@@ -55,10 +56,6 @@ async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
await expectFileTabOpen(page, filename);
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
function htmlPreview(page: Page) {
return {
host: page.getByTestId("file-html-preview"),
@@ -66,13 +63,32 @@ function htmlPreview(page: Page) {
};
}
/** Switches the file tab to one of its panels from the bar's switcher. */
async function selectFilePanel(page: Page, title: string): Promise<void> {
const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true });
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
const option = page.getByTestId("file-panel-bar").getByRole("button", {
name: view,
exact: true,
});
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
async function expectAbove(upper: Locator, lower: Locator): Promise<void> {
const [upperBox, lowerBox] = await Promise.all([upper.boundingBox(), lower.boundingBox()]);
expect(upperBox).not.toBeNull();
expect(lowerBox).not.toBeNull();
expect(upperBox!.y + upperBox!.height).toBeLessThanOrEqual(lowerBox!.y);
}
/** How many times the app has read the file's contents from the daemon. */
function fileReadCount(
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
fileName: string,
): number {
return gate
.getClientRequests("file_explorer_request")
.filter((request) => request.mode === "file" && String(request.path).endsWith(fileName)).length;
}
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
const image = page.getByRole("button", { name, exact: true });
await expect(image).toBeVisible();
@@ -482,9 +498,9 @@ test.describe("CodeMirror workspace file editing", () => {
relativePath,
);
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
const modeControl = filePanelSwitcher(page);
const modeControl = page.getByTestId("file-preview-mode");
await expect(modeControl).toBeVisible();
await selectFilePanel(page, "Source");
await selectFileView(page, "Source");
const editorHost = page.getByTestId("file-source-editor");
const content = editor(page);
@@ -546,7 +562,7 @@ test.describe("CodeMirror workspace file editing", () => {
);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
await selectFilePanel(page, "Source");
await selectFileView(page, "Source");
const markdownScroller = page
.getByTestId("file-source-editor")
@@ -582,6 +598,11 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(page.getByLabel(/File size/)).toBeVisible();
await expect(page.getByLabel(/lines/)).toBeVisible();
await expect(page.getByTestId("file-preview-mode")).toHaveCount(0);
await expectAbove(
page.getByTestId("file-panel-bar").filter({ visible: true }),
page.getByTestId("file-source-editor").filter({ visible: true }),
);
await replaceEditorText(page, "const autosaved = 2;\n");
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
@@ -678,11 +699,12 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
});
test("refreshes Markdown and images while switching between Markdown and Source", async ({
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
const markdownPath = path.join(workspace.repoPath, "notes.md");
const imagePath = path.join(workspace.repoPath, "pixel.png");
@@ -692,17 +714,30 @@ test.describe("CodeMirror workspace file editing", () => {
await openWorkspaceFile(page, "notes.md");
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
const toolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible();
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
await expect(toolbar.getByLabel(/^File size/)).toBeVisible();
await expect(toolbar.getByLabel("2 lines")).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8");
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
await selectFilePanel(page, "Source");
// Source shows the same read in the editor; the file is not read again.
const reads = fileReadCount(gate, "notes.md");
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText("# Updated heading");
await expect(toolbar.getByLabel("Line 1, column 1")).toBeVisible();
expect(fileReadCount(gate, "notes.md")).toBe(reads);
// Preview shows the edits made in Source.
await replaceEditorText(page, "# Saved from source\n");
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await selectFilePanel(page, "Markdown");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
await selectFileView(page, "Preview");
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
await expect(toolbar.getByLabel("Line 1, column 1")).toHaveCount(0);
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).not.toBeVisible();
await openWorkspaceFile(page, "pixel.png");
const image = visibleFilePane.locator("img");
@@ -784,10 +819,10 @@ test.describe("CodeMirror workspace file editing", () => {
);
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
await selectFilePanel(page, "Source");
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(preview.host).toHaveCount(0);
await selectFilePanel(page, "HTML");
await selectFileView(page, "Preview");
await expect(preview.host).toBeVisible();
});
@@ -14,9 +14,8 @@ async function openMarkdownPreview(page: Page) {
await openFileFromExplorer(page, "README.md");
await expectFileTabOpen(page, "README.md");
await page
.getByTestId("file-panel-switcher")
.filter({ visible: true })
.getByRole("button", { name: "Markdown", exact: true })
.getByTestId("file-panel-bar")
.getByRole("button", { name: "Preview", exact: true })
.click();
await ensureWorkspaceAgentPaneVisible(page);
}
@@ -95,7 +95,7 @@ test.describe("New workspace entry points", () => {
}
});
test("the New Workspace screen hides the host selector when there is only one host", async ({
test("the New Workspace screen shows the host selector with Add host when there is only one host", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" });
@@ -120,7 +120,12 @@ test.describe("New workspace entry points", () => {
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
const hostTrigger = page.getByTestId("host-picker-trigger");
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
await hostTrigger.click();
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
await page.getByTestId("new-workspace-host-picker-add-host").click();
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
} finally {
await seeded.cleanup();
}
@@ -71,10 +71,6 @@ async function captureWideAndCompact(
await ready();
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
/** Turns on vim keybindings without discarding settings the test writes later. */
async function enableVimKeybindings(page: Page) {
await page.addInitScript(() => {
@@ -94,16 +90,19 @@ async function replaceMarkdown(page: Page, text: string) {
await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text);
}
async function selectPanel(page: Page, title: string) {
await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click();
async function openWithFromTabMenu(page: Page, name: string, option: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await page.getByRole("menuitem", { name: "Open with…" }).hover();
await page.getByRole("menuitem", { name: option }).click();
}
async function expectPanels(page: Page, titles: string[]) {
for (const title of titles) {
await expect(
filePanelSwitcher(page).getByRole("button", { name: title, exact: true }),
).toBeInViewport({ ratio: 1 });
}
async function selectFileView(page: Page, view: "Preview" | "Source") {
const option = page
.getByTestId("file-preview-mode")
.filter({ visible: true })
.getByRole("button", { name: view, exact: true });
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
@@ -126,9 +125,6 @@ async function expectMarkdownEditor(page: Page, text: string) {
await expect(editor).toBeVisible();
await expect(editor).toContainText(text);
await expect(editor).toBeInViewport({ ratio: 1 });
// Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu.
await expect(filePanelSwitcher(page)).toContainText("Rich Markdown");
await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 });
}
test("directory examples mount the PDF frame and write Markdown through the file handle", async ({
@@ -197,7 +193,7 @@ test("directory examples mount the PDF frame and write Markdown through the file
await expect(
page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }),
).toBeVisible();
await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
await expect(page.getByRole("radio")).toHaveCount(2);
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
// The previous layout's picker animates out (the sheet slides down, the dialog fades), so
// both pickers are mounted for a moment after a resize.
@@ -220,32 +216,23 @@ test("directory examples mount the PDF frame and write Markdown through the file
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]);
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
await expectMarkdownEditor(page, "Edited by Rich Markdown");
});
// Source is Paseo's editor; its cursor and vim mode are in its own status row.
await selectPanel(page, "Source");
// "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the
// cursor and vim mode in the panel's toolbar.
await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo");
await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible();
await selectFileView(page, "Source");
const source = page.getByTestId("file-source-editor").filter({ visible: true });
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
await source.locator(".cm-line").nth(2).click();
const bar = page.getByTestId("file-source-status").filter({ visible: true });
const bar = page.getByTestId("file-panel-bar").filter({ visible: true });
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
await capture(page, testInfo, "markdown-example-source-wide");
// The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source.
await page.setViewportSize(COMPACT);
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
await filePanelSwitcher(page).click();
await page.getByRole("menuitem", { name: "Source", exact: true }).click();
await expect(filePanelSwitcher(page)).toHaveText("Source");
await source.locator(".cm-line").nth(2).click();
await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport();
await capture(page, testInfo, "markdown-example-source-compact");
await page.setViewportSize(WIDE);
await selectPanel(page, "Rich Markdown");
await openWithFromTabMenu(page, "notes.md", "Rich Markdown — markdown-panel");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText(
"# External update",
@@ -266,7 +253,6 @@ test("directory examples mount the PDF frame and write Markdown through the file
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await page.setViewportSize(COMPACT);
await expectMarkdownEditor(page, "Remembered panel");
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
} finally {
await client.removePlugin("pdf-panel");
await client.removePlugin("markdown-panel");
@@ -30,17 +30,26 @@ async function openWithFromTabMenu(page: Page, name: string, option: string) {
await page.getByRole("menuitem", { name: option }).click();
}
async function toggleAlwaysUseFromFileBar(page: Page) {
await page.getByTestId("file-panel-bar-menu").filter({ visible: true }).click();
await page.getByRole("menuitem", { name: "Always use for .md" }).click();
async function toggleAlwaysUseFromTabMenu(page: Page, name: string) {
await openWithFromTabMenu(page, name, "Always use for .md");
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
/** The built-in panel's toolbar control between its Preview and Source views. */
function viewControl(page: Page) {
return page.getByTestId("file-preview-mode").filter({ visible: true });
}
async function expectSwitcherPanels(page: Page, titles: string[]) {
await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles);
async function selectFileView(page: Page, view: "Preview" | "Source") {
const option = viewControl(page).getByRole("button", { name: view, exact: true });
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
async function expectNoOpenWith(page: Page, name: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Close", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Open with…" })).toHaveCount(0);
await page.keyboard.press("Escape");
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
@@ -49,7 +58,7 @@ async function capture(page: Page, testInfo: TestInfo, name: string) {
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
test("opens Markdown with the built-in panel and Source beside it without asking", async ({
test("opens Markdown with the built-in panel and its Preview and Source without asking", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
@@ -62,19 +71,20 @@ test("opens Markdown with the built-in panel and Source beside it without asking
await openFileExplorer(page);
await openMarkdown(page, "notes.md");
await expectBuiltinMarkdown(page, "notes heading");
await expectSwitcherPanels(page, ["Markdown", "Source"]);
await expect(viewControl(page).getByRole("button")).toHaveText(["Preview", "Source"]);
await expect(filePanelPicker(page)).toBeHidden();
await expectNoOpenWith(page, "notes.md");
await capture(page, testInfo, "file-panel-builtins-wide");
await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click();
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click();
await selectFileView(page, "Preview");
await expectBuiltinMarkdown(page, "notes heading");
await page.setViewportSize({ width: 390, height: 844 });
await expectBuiltinMarkdown(page, "notes heading");
await expect(
filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }),
viewControl(page).getByRole("button", { name: "Source", exact: true }),
).toBeInViewport({ ratio: 1 });
await expect(filePanelPicker(page)).toBeHidden();
await capture(page, testInfo, "file-panel-builtins-compact");
@@ -107,6 +117,7 @@ test("asks once which panel opens Markdown and remembers it when told to", async
await expect(filePanelPicker(page)).toBeVisible();
await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked();
await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked();
await expect(page.getByRole("radio")).toHaveCount(2);
await expectBuiltinMarkdown(page, "first heading");
await capture(page, testInfo, "file-panel-picker-wide");
@@ -118,8 +129,8 @@ test("asks once which panel opens Markdown and remembers it when told to", async
await expectPluginMarkdown(page, "second.md");
await expect(filePanelPicker(page)).toBeHidden();
// Forgetting from the file bar keeps the tab on the plugin.
await toggleAlwaysUseFromFileBar(page);
// Forgetting from the tab menu keeps the tab on the plugin.
await toggleAlwaysUseFromTabMenu(page, "second.md");
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
await expectPluginMarkdown(page, "second.md");
@@ -130,10 +141,14 @@ test("asks once which panel opens Markdown and remembers it when told to", async
await expectPluginMarkdown(page, "third.md");
expect(await storedFilePanelAssociations(page)).toEqual({});
// "Open with…" switches the open tab back to the built-in in place.
// "Open with…" switches the open tab back to the built-in in place, with its own views.
await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION);
await expectBuiltinMarkdown(page, "third heading");
await expectFileTabOpen(page, "third.md");
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
await selectFileView(page, "Preview");
await expectBuiltinMarkdown(page, "third heading");
// Dismissing keeps the built-in; reopening the tab after a reload asks again.
await openMarkdown(page, "fourth.md");
@@ -7,7 +7,6 @@ import {
BUILTIN_MARKDOWN_OPTION,
MARKDOWN_PANEL_ID,
PLUGIN_MARKDOWN_OPTION,
SOURCE_OPTION,
expectPluginMarkdown,
filePanelPicker,
installMarkdownPanelPlugin,
@@ -18,6 +17,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { openSettingsSection } from "../support/helpers/settings";
const ASK_EVERY_TIME = "Ask every time";
const SOURCE_OPTION = "Source — Paseo";
function markdownRow(page: Page) {
return page.getByTestId("file-panel-settings-row-ext:md");
@@ -69,7 +69,7 @@ test("Settings > Files chooses which panel opens .md and asks again on request",
await markdownDropdown(page, ASK_EVERY_TIME).click();
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toHaveCount(0);
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-wide-menu");
await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click();
@@ -94,11 +94,11 @@ test("Settings > Files chooses which panel opens .md and asks again on request",
await expect(askAgain(page)).toBeInViewport({ ratio: 1 });
await capture(page, testInfo, "file-panel-settings-compact");
await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-compact-menu");
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeHidden();
// Ask again forgets the choice; the next open asks.
await askAgain(page).click();
@@ -91,9 +91,13 @@ async function associateMarkdownWithPlugin(page: Page) {
async function editMarkdownThroughPlugin(page: Page) {
await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click();
}
async function selectSource(page: Page) {
/** Opens the tab with Paseo's Markdown panel from "Open with…" and shows its Source view. */
async function openBuiltinMarkdownSource(page: Page, name: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await page.getByRole("menuitem", { name: "Open with…" }).hover();
await page.getByRole("menuitem", { name: "Markdown — Paseo" }).click();
await page
.getByTestId("file-panel-switcher")
.getByTestId("file-preview-mode")
.filter({ visible: true })
.getByRole("button", { name: "Source", exact: true })
.click();
@@ -210,7 +214,7 @@ test("directory plugins render PDF bytes and write Markdown through the file han
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
await expect.poll(() => closePrompt).toContain("discard");
await selectSource(page);
await openBuiltinMarkdownSource(page, "notes.md");
await expect(
page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"),
).toHaveText("# Changed outside");
@@ -10,7 +10,7 @@ import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspac
const PLUGIN_ID = "plugin-host-ui-e2e";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
import { Icon, Modal, useToast, Markdown } from "@getpaseo/plugin/client/ui";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -27,6 +27,9 @@ function ModalBody({ onSaved }) {
return <View>
<Text>Plugin modal contexts ready</Text>
<Markdown>{"## Plugin report"}</Markdown>
<Markdown>{"Checks **passed**. [Documentation](https://paseo.sh/docs/plugins)"}</Markdown>
<Markdown>{""}</Markdown>
<Pressable accessibilityRole="button" onPress={save}>
<Text>Save issue</Text>
</Pressable>
@@ -92,10 +95,20 @@ async function openPluginModal(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
}
async function expectPluginMarkdown(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Plugin report" })).toBeVisible();
await expect(page.getByText("Checks passed.", { exact: false })).toBeVisible();
await expect(page.getByRole("link", { name: "Documentation", exact: true })).toHaveAttribute(
"href",
"https://paseo.sh/docs/plugins",
);
}
async function expectCenteredPluginDialog(page: Page): Promise<void> {
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
await expectPluginMarkdown(page);
}
async function closeCenteredPluginDialog(page: Page): Promise<void> {
@@ -107,6 +120,7 @@ async function closeCenteredPluginDialog(page: Page): Promise<void> {
async function expectCompactPluginSheet(page: Page): Promise<void> {
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
await expectPluginMarkdown(page);
}
async function savePluginIssue(page: Page): Promise<void> {
@@ -6,6 +6,7 @@ import {
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { expectOpenTabCount } from "../support/helpers/sheet";
import { getServerId } from "../support/helpers/server-id";
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
@@ -227,3 +228,45 @@ test("manual unread survives leaving the current workspace on compact layout", a
await markUnreadThenResumeChat(page, workspaces.subject.workspaceId);
await completeTurnAndLeaveOnCompact(page, workspaces);
});
async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
const route = page.url();
// End this app session before the turn, so its cached transcript cannot contain the new prompt.
await page.goto("about:blank");
await workspace.client.sendAgentMessage(
workspace.agentId,
"A turn completed while the app was closed.",
);
await workspace.client.waitForFinish(workspace.agentId, 20_000);
// Reopen the ordinary workspace route, without an agent open intent that would repair focus.
await page.goto(route);
await expect(page.getByRole("button", { name: /Switch tabs/ })).toBeVisible();
}
async function expectRestoredCompactChat(page: Page) {
await expectOpenTabCount(page, 1);
await expect(
page.getByText("A turn completed while the app was closed.", { exact: true }),
).toBeVisible();
}
test("reopening a marked unread workspace restores only its agent tab and current history on compact layout", async ({
page,
workspaces,
}) => {
test.setTimeout(120_000);
await test.step("open a finished chat with one main tab", async () => {
await openCompactWorkspace(page, workspaces.subject);
await closeMobileAgentSidebar(page);
await expectOpenTabCount(page, 1);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
});
await test.step("mark the current workspace unread, then close and reopen the app", async () => {
await openMobileAgentSidebar(page);
await markAsUnread(page, workspaces.subject.workspaceId);
await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject);
});
await test.step("restore the main tab without Explorer entries and load its latest history", async () => {
await expectRestoredCompactChat(page);
});
});
@@ -404,7 +404,7 @@ async function fetchWorkspaceStatuses(
}
test.describe("Workspace model restart regressions", () => {
test("browser-created same-cwd workspace preserves restarted agent status and migrated tab ownership", async ({
test("browser-created same-cwd workspace preserves repaired restart status and migrated tab ownership", async ({
page,
baseURL,
}) => {
@@ -423,9 +423,13 @@ test.describe("Workspace model restart regressions", () => {
.toMatchObject({
id: LEGACY_AGENT_ID,
workspaceId: seeded.workspaceA,
status: "running",
status: "closed",
});
await expect
.poll(() => fetchWorkspaceStatuses(client, [seeded.workspaceA, seeded.workspaceB]))
.toEqual({ [seeded.workspaceA]: "done", [seeded.workspaceB]: "done" });
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await waitForSidebarHydration(page);
await expectWorkspaceRowDoesNotShowIndicator(page, {
@@ -472,15 +476,11 @@ test.describe("Workspace model restart regressions", () => {
]),
)
.toMatchObject({
[seeded.workspaceA]: "done",
[seeded.workspaceB]: "done",
[createdWorkspaceId]: "done",
});
// The restarted provider session may settle while the browser creates the sibling. Its
// initial running status is asserted above; this phase verifies that ownership never moves.
const workspaceStatuses = await fetchWorkspaceStatuses(client, [seeded.workspaceA]);
expect(["running", "done"]).toContain(workspaceStatuses[seeded.workspaceA]);
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: seeded.workspaceB,
@@ -18,6 +18,9 @@ export async function openCommandCenterForAgent(
workspace: Pick<MockAgentWorkspace, "workspaceId" | "agentId">,
): Promise<Locator> {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await expect(
page.getByRole("button", { name: /^Select agent mode \(/ }).filter({ visible: true }),
).toBeVisible({ timeout: 30_000 });
const panel = await openCommandCenter(page);
return panel.getByTestId("command-center-input");
}
@@ -11,7 +11,6 @@ export const MARKDOWN_PANEL_PLUGIN_ID = "file-panel-picker-e2e";
export const MARKDOWN_PANEL_ID = `plugin:${MARKDOWN_PANEL_PLUGIN_ID}/rich-markdown`;
export const BUILTIN_MARKDOWN_OPTION = "Markdown — Paseo";
export const PLUGIN_MARKDOWN_OPTION = `Rich Markdown — ${MARKDOWN_PANEL_PLUGIN_ID}`;
export const SOURCE_OPTION = "Source — Paseo";
const SOURCE = `import React from "react";
import { Text, View } from "react-native";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -0,0 +1,59 @@
import { X } from "lucide-react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedX = withUnistyles(X);
const NO_SHORTCUT: ShortcutKey[] = [];
interface SidebarCloseButtonProps {
label: string;
onPress: () => void;
testID: string;
nativeID?: string;
accessibilityState?: { expanded: boolean };
style?: StyleProp<ViewStyle>;
}
/**
* The X that closes a compact sidebar overlay. It is the compact header's icon button, so the
* sidebar's close reads like the header it covers; every compact sidebar uses it.
*/
export function SidebarCloseButton({
label,
onPress,
testID,
nativeID,
accessibilityState,
style,
}: SidebarCloseButtonProps) {
return (
<IconButtonChromeProvider value="header-island">
<HeaderToggleButton
onPress={onPress}
tooltipLabel={label}
tooltipKeys={NO_SHORTCUT}
tooltipSide="bottom"
style={style}
testID={testID}
nativeID={nativeID}
accessible
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={accessibilityState}
>
<ThemedX
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
</IconButtonChromeProvider>
);
}
+11 -40
View File
@@ -5,7 +5,6 @@ import {
GitBranch,
Server,
Settings,
X,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -21,14 +20,9 @@ import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
@@ -68,7 +62,6 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import {
@@ -81,8 +74,6 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
const ThemedX = withUnistyles(X);
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
@@ -555,7 +546,15 @@ function MobileSidebar({
}, [closeSidebar]);
const closeButton = useMemo(
() => <SidebarCloseButton label={labels.closeSidebar} onPress={closeSidebar} />,
() => (
<SidebarCloseButton
label={labels.closeSidebar}
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
style={styles.mobileCloseButton}
/>
),
[labels.closeSidebar, closeSidebar],
);
@@ -621,34 +620,6 @@ function MobileSidebar({
);
}
const NO_SHORTCUT: ShortcutKey[] = [];
/** The compact header's icon button, so the sidebar's close reads like the header it covers. */
function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) {
return (
<IconButtonChromeProvider value="header-island">
<HeaderToggleButton
onPress={onPress}
tooltipLabel={label}
tooltipKeys={NO_SHORTCUT}
tooltipSide="bottom"
style={styles.mobileCloseButton}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel={label}
>
<ThemedX
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
</IconButtonChromeProvider>
);
}
function DesktopSidebar({
theme,
workspaceGroups,
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from "react";
import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
import type { GestureResponderEvent } from "react-native";
import * as Haptics from "expo-haptics";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
@@ -36,10 +36,9 @@ export function useLongPressDragInteraction(input: {
if (!menuController || !touchStartRef.current) {
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
menuController.setAnchorRect({
x: touchStartRef.current.x,
y: touchStartRef.current.y + statusBarHeight,
y: touchStartRef.current.y,
width: 0,
height: 0,
});
@@ -6,8 +6,6 @@ import {
type Ref,
} from "react";
import {
Platform,
StatusBar,
View,
type GestureResponderEvent,
type PressableProps,
@@ -133,10 +131,9 @@ export function ContextMenuTrigger({
const point = coerceEventPoint(event);
if (!point) return;
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
ctx.setAnchorRect({
x: point.pageX,
y: point.pageY + statusBarHeight,
y: point.pageY,
width: 0,
height: 0,
});
@@ -14,7 +14,6 @@ import {
Modal,
Platform,
Pressable,
StatusBar,
View,
type StyleProp,
type ViewStyle,
@@ -162,16 +161,13 @@ export function useAnchoredPosition({
return undefined;
}
// Capture status bar height synchronously before async measurement, so it cannot change
// or read back null between the measure call and its resolution.
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
// The app draws edge-to-edge, so measureInWindow and the full-screen Modal share one
// coordinate space that starts at the screen top; no status bar offset applies.
void measureElement(anchorRef.current).then((rect) => {
if (cancelled) return undefined;
// On Android with statusBarTranslucent, measureInWindow returns coordinates relative to
// below the status bar while Modal content starts at the screen top.
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
setTriggerRect(rect);
return undefined;
});
+1 -3
View File
@@ -18,7 +18,6 @@ import {
Platform,
Modal,
Pressable,
StatusBar,
View,
type PressableProps,
type StyleProp,
@@ -474,11 +473,10 @@ export function TooltipContent({
return () => {};
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
void measureElement(ctx.triggerRef.current).then((rect) => {
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
if (!cancelled) setTriggerRect(rect);
return undefined;
});
+1 -1
View File
@@ -54,7 +54,7 @@ export interface ComposerSendClient {
images: Array<{ data: string; mimeType: string }>;
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
},
) => Promise<void>;
) => Promise<unknown>;
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
requestId: string;
file: {
+7 -6
View File
@@ -51,7 +51,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
emit: <K extends RouterMessageType>(message: Extract<RouterMessage, { type: K }>) => void;
subscribeCheckoutDiffCalls: Array<{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean };
subscriptionId: string;
}>;
unsubscribeCheckoutDiffCalls: string[];
@@ -67,7 +67,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
};
const subscribeCheckoutDiffCalls: Array<{
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean };
subscriptionId: string;
}> = [];
const unsubscribeCheckoutDiffCalls: string[] = [];
@@ -379,12 +379,12 @@ describe("server data push router", () => {
});
});
it("subscribes active checkout diff queries and writes matching diff events", () => {
it.each(["base", "all"] as const)("routes %s diff snapshots and updates", (mode) => {
const queryClient = new QueryClient();
const fake = createFakeClient();
const serverId = "server-1";
const cwd = "/repo";
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
const queryKey = checkoutDiffQueryKey(serverId, cwd, mode, "main", true);
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
const observer = new QueryObserver(queryClient, {
queryKey,
@@ -397,17 +397,18 @@ describe("server data push router", () => {
serverId,
subscriptionId,
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
compare: { mode, baseRef: "main", ignoreWhitespace: true },
}),
});
const unsubscribeObserver = observer.subscribe(() => undefined);
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
expect(fake.subscribeCheckoutDiffCalls).toHaveLength(1);
const serverSubscriptionId = fake.subscribeCheckoutDiffCalls[0]!.subscriptionId;
expect(fake.subscribeCheckoutDiffCalls).toEqual([
{
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
compare: { mode, baseRef: "main", ignoreWhitespace: true },
subscriptionId: serverSubscriptionId,
},
]);
+2 -2
View File
@@ -33,7 +33,7 @@ type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionI
type ListTerminalsPayload = ListTerminalsResponse["payload"];
interface CheckoutDiffCompare {
mode: "uncommitted" | "base";
mode: "uncommitted" | "base" | "all";
baseRef?: string;
ignoreWhitespace?: boolean;
}
@@ -655,7 +655,7 @@ function readCheckoutDiffCompare(value: unknown): CheckoutDiffCompare | null {
const mode = value.mode;
const baseRef = value.baseRef;
const ignoreWhitespace = value.ignoreWhitespace;
if (mode !== "uncommitted" && mode !== "base") {
if (mode !== "uncommitted" && mode !== "base" && mode !== "all") {
return null;
}
if (baseRef !== undefined && typeof baseRef !== "string") {
-58
View File
@@ -1,58 +0,0 @@
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import {
FilePanelBarMenu,
FilePanelSwitcher,
useFilePanelChoiceSnapshot,
type FilePanelChoice,
} from "@/file-panels";
/**
* The file tab's bar: the switcher between the file's panels, "Open with…", and whether the panel
* has unsaved changes. Shown only when the file has more than one panel; the panel shows the rest.
*/
export function FilePanelBar({ choice, modified }: { choice: FilePanelChoice; modified: boolean }) {
const { t } = useTranslation();
const { openWith } = useFilePanelChoiceSnapshot(choice);
if (!openWith) return null;
return (
<View style={styles.chrome}>
<PaneContentToolbar testID="file-panel-bar">
<View style={styles.row}>
<View style={styles.spacer} />
{modified ? (
<View
style={styles.modifiedDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
testID="file-panel-modified"
/>
) : null}
<FilePanelSwitcher choice={choice} />
<FilePanelBarMenu choice={choice} />
</View>
</PaneContentToolbar>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
chrome: {
flexShrink: 0,
},
row: {
height: "100%",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
},
spacer: { flex: 1 },
modifiedDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundExtraMuted,
},
}));
-2
View File
@@ -15,7 +15,6 @@ import { useSessionStore } from "@/stores/session-store";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { formatFileSize } from "@/utils/format-file-size";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { FilePanelBar } from "./bar";
/**
* The file tab: stats the file, picks its panel and mounts it. The panel reads, saves and shows
@@ -110,7 +109,6 @@ export function FilePane({
return (
<View style={styles.container} testID="workspace-file-pane">
<FilePanelPicker choice={choice} />
<FilePanelBar choice={choice} modified={modified} />
<MountedFilePanel
key={`${panel.id}:${targetKey}`}
panel={panel}
+23 -13
View File
@@ -1,4 +1,4 @@
import { useFileText, useFileUrl } from "@getpaseo/plugin/client";
import { useFileUrl } from "@getpaseo/plugin/client";
import { useCallback, useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -8,23 +8,35 @@ import { FileHtmlPreview } from "./html-preview";
import { FilePanelLoading } from "./loading";
import { FileMarkdownPreview } from "./markdown-preview";
import type { BuiltinFilePanelProps } from "./panels";
import { SourcePanel } from "./source/panel";
import { TextFilePanel, type TextFilePreviewProps } from "./source/panel";
import type { RenderingFilePanel, SourceFilePanel } from "./catalog";
/** Shows Loading until the rendered Markdown has laid out once; later re-reads keep the content. */
function MarkdownPanel({ file, target }: BuiltinFilePanelProps) {
const text = useFileText(file);
/** Source: the text file panel with no Preview, for text files no other built-in panel claims. */
function SourcePanel(props: BuiltinFilePanelProps) {
return <TextFilePanel {...props} />;
}
/** Markdown: the text file panel with the rendered document as its Preview. */
function MarkdownPanel(props: BuiltinFilePanelProps) {
return <TextFilePanel {...props} preview={MarkdownPreview} />;
}
/** HTML: the text file panel with the sandboxed page as its Preview. */
function HtmlPanel(props: BuiltinFilePanelProps) {
return <TextFilePanel {...props} preview={HtmlPreview} />;
}
/** Shows Loading until the rendered Markdown has laid out once; later edits keep the content. */
function MarkdownPreview({ text, target }: TextFilePreviewProps) {
const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null);
const [rendered, setRendered] = useState(false);
const handleLayout = useCallback(() => setRendered(true), []);
if (text.status === "loading") return <FilePanelLoading />;
if (text.status === "error") return <FilePanelError message={text.error.message} />;
return (
<View style={styles.container}>
<ScrollView style={styles.container} showsVerticalScrollIndicator>
<View onLayout={handleLayout}>
<FileMarkdownPreview
source={text.text}
source={text}
filePath={target.path}
workspaceRoot={target.workspaceRoot}
client={client}
@@ -37,13 +49,11 @@ function MarkdownPanel({ file, target }: BuiltinFilePanelProps) {
);
}
function HtmlPanel({ file }: BuiltinFilePanelProps) {
const text = useFileText(file);
if (text.status === "loading") return <FilePanelLoading />;
if (text.status === "error") return <FilePanelError message={text.error.message} />;
/** The page owns its scrolling, so there is no ScrollView around it. */
function HtmlPreview({ text }: TextFilePreviewProps) {
return (
<View style={styles.container}>
<FileHtmlPreview html={text.text} testID="file-html-preview" />
<FileHtmlPreview html={text} testID="file-html-preview" />
</View>
);
}
+18 -4
View File
@@ -25,8 +25,9 @@ function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[]
}
describe("built-in file panels", () => {
it("opens each rendered extension with its panel and Source after it", () => {
expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown", "builtin:source"]);
it("opens each rendered extension with its panel alone, never with Source beside it", () => {
expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown"]);
expect(candidateIds(textFile("plan.html"))).toEqual(["builtin:html"]);
expect(panelId(textFile("notes.markdown"))).toBe("builtin:markdown");
expect(panelId(textFile("plan.html"))).toBe("builtin:html");
expect(panelId(textFile("docs/PLAN.HTML"))).toBe("builtin:html");
@@ -39,16 +40,29 @@ describe("built-in file panels", () => {
}
});
it("puts plugin panels after the rendering built-ins and before Source", () => {
it("puts plugin panels after the built-in that claims the file, and before Source", () => {
expect(candidateIds(textFile("README.md"), filePanelCatalog([richMarkdown]))).toEqual([
"builtin:markdown",
richMarkdown.id,
"builtin:source",
]);
const onlyPlugin = filePanelCatalog([{ ...richMarkdown, files: { extensions: ["txt"] } }]);
expect(candidateIds(textFile("notes.txt"), onlyPlugin)).toEqual([
richMarkdown.id,
"builtin:source",
]);
expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id);
});
it("ignores a remembered Source for a file another built-in claims", () => {
const resolution = resolveFilePanel({
file: textFile("README.md"),
candidates: builtins,
associations: { "ext:md": "builtin:source" },
});
expect(resolution.panel?.id).toBe("builtin:markdown");
expect(resolution.associatedPanelId).toBeNull();
});
it("has no Source for files that are not text", () => {
expect(candidateIds({ path: "archive.zip", kind: "binary" })).toEqual([]);
});
+5 -2
View File
@@ -21,7 +21,10 @@ const renderingPanels = [
},
] as const satisfies readonly FilePanelCandidate[];
/** Paseo's editor. Every text file has it, so a text file always opens with some panel. */
/**
* Paseo's editor, the floor for text files: every text file no other built-in claims has it, so a
* text file always opens with some panel. A built-in that claims a file owns its source view.
*/
export const sourceFilePanel = {
id: "builtin:source",
builtin: true,
@@ -34,7 +37,7 @@ export type SourceFilePanel = typeof sourceFilePanel;
/**
* Every panel a host offers, in resolver order: the built-ins that render a file, then plugin
* panels, then Source. Source is last so it is the default only when nothing else claims the file.
* panels, then Source. Source is last so a plugin panel is the default over it.
*/
export function filePanelCatalog<Plugin extends FilePanelCandidate>(
pluginPanels: readonly Plugin[],
+1 -4
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
import { usePaneContext } from "@/panels/pane-context";
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
import { useFilePanelAssociations } from "../associations";
import { sourceFilePanel } from "../catalog";
import { useFilePanels, type FilePanel } from "../panels";
import { resolveFilePanel, type FilePanelFileKind } from "../resolve";
import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model";
@@ -29,9 +28,7 @@ export function useFilePanelChoice(input: {
const { serverId, workspaceId, tabId } = usePaneContext();
const candidates = useFilePanels(input.serverId);
const { associations, remember } = useFilePanelAssociations();
const [choice] = useState(
() => new FilePanelChoiceModel<FilePanel>({ remember, linePanelId: sourceFilePanel.id }),
);
const [choice] = useState(() => new FilePanelChoiceModel<FilePanel>({ remember }));
// From the subscribed snapshot: React Compiler caches reads off the stable model object.
const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey);
const resolution = useMemo(
@@ -8,6 +8,11 @@ const rich: FilePanelCandidate = {
builtin: false,
files: { extensions: ["md", "markdown"] },
};
const richTypeScript: FilePanelCandidate = {
id: "plugin:ts/outline",
builtin: false,
files: { extensions: ["ts"] },
};
const markdown = "builtin:markdown";
const source = sourceFilePanel.id;
@@ -22,7 +27,6 @@ function openTab(
if (panelId === null) delete associations[selectorKey];
else associations[selectorKey] = panelId;
},
linePanelId: source,
});
let targetKey = `/repo:${options.path ?? "notes.md"}`;
let presentable = options.presentable ?? true;
@@ -70,11 +74,7 @@ describe("FilePanelChoiceModel", () => {
selectedPanelId: markdown,
remember: false,
});
expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([
markdown,
rich.id,
source,
]);
expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([markdown, rich.id]);
tab.model.dismissPicker();
expect(tab.picker()).toBeNull();
expect(tab.panel()).toBe(markdown);
@@ -84,17 +84,13 @@ describe("FilePanelChoiceModel", () => {
expect(openTab(associations).picker()).not.toBeNull();
});
it("never asks between built-in panels, and switches between them", () => {
it("opens a file a built-in claims with that built-in alone and nothing to choose", () => {
const tab = openTab({}, [...filePanelCatalog([])]);
expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toBeNull();
expect(tab.model.getSnapshot().openWith?.candidates.map((candidate) => candidate.id)).toEqual([
markdown,
source,
]);
expect(tab.model.getSnapshot().openWith).toBeNull();
tab.model.openWith(source);
expect(tab.panel()).toBe(source);
expect(tab.picker()).toBeNull();
expect(tab.panel()).toBe(markdown);
});
it("opens a file only Source claims with Source and nothing to choose", () => {
@@ -133,7 +129,7 @@ describe("FilePanelChoiceModel", () => {
it("does not ask with an association or while the tab is in the background", () => {
expect(openTab({ "ext:md": rich.id }).picker()).toBeNull();
expect(openTab({ "ext:md": source }).panel()).toBe(source);
expect(openTab({ "ext:md": source }).panel()).toBe(markdown);
const background = openTab({}, undefined, { presentable: false });
expect(background.picker()).toBeNull();
@@ -141,21 +137,29 @@ describe("FilePanelChoiceModel", () => {
expect(background.picker()).not.toBeNull();
});
it("opens Source for a line target without asking, and again on each new one", () => {
const tab = openTab({}, undefined, { lineNavigation: 1 });
expect(tab.panel()).toBe(source);
it("opens the built-in panel for a line target without asking, and again on each new one", () => {
const tab = openTab({ "ext:md": rich.id }, undefined, { lineNavigation: 1 });
expect(tab.panel()).toBe(markdown);
expect(tab.picker()).toBeNull();
tab.model.openWith(rich.id);
expect(tab.panel()).toBe(rich.id);
expect(tab.navigateToLine(1)).toBe(rich.id);
expect(tab.navigateToLine(2)).toBe(source);
expect(tab.navigateToLine(2)).toBe(markdown);
});
it("opens Source for the same line target in a retargeted tab", () => {
it("opens Source for a line target in a file only Source and a plugin claim", () => {
const tab = openTab({ "ext:ts": richTypeScript.id }, [...filePanelCatalog([richTypeScript])], {
path: "index.ts",
});
expect(tab.panel()).toBe(richTypeScript.id);
expect(tab.navigateToLine(1)).toBe(source);
});
it("opens the built-in panel for the same line target in a retargeted tab", () => {
const tab = openTab({}, undefined, { lineNavigation: 1 });
tab.model.openWith(markdown);
expect(tab.retarget("other.md")).toBe(source);
tab.model.openWith(rich.id);
expect(tab.retarget("other.md")).toBe(markdown);
});
it("starts a new open when the tab is retargeted", () => {
+12 -5
View File
@@ -30,15 +30,13 @@ export interface FilePanelChoiceSnapshot<Candidate> {
/** The panel picked for this tab open. Resolution prefers it over the association. */
chosenPanelId: string | null;
picker: FilePanelPickerState<Candidate> | null;
/** Present only when the file has more than one candidate; drives the bar's panel switcher. */
/** Present only when the file has more than one candidate; drives "Open with…". */
openWith: FilePanelOpenWithState<Candidate> | null;
}
export interface FilePanelChoiceDeps {
/** Writes (or clears, with null) the device-wide association for a selector key. */
remember(selectorKey: string, panelId: string | null): void;
/** The panel a line target opens: Source, the panel that shows lines. */
linePanelId: string;
}
/** The tab choice that applies to `targetKey`; a choice never outlives its open. */
@@ -61,7 +59,8 @@ const EMPTY: FilePanelChoiceSnapshot<never> = {
*
* A tab open is one target in one tab: the picker is offered at most once per open, the first
* time the tab is on screen with a plugin panel among several candidates and nothing remembered.
* Built-in panels alone never ask. A line target opens the line panel the way "Open with…" does.
* Built-in panels alone never ask. A line target opens the file's built-in panel, which shows
* lines, the way "Open with…" does.
* Retargeting the tab starts a new open. Choices made here apply to this tab; only "Don't ask me
* again" and "Always use" write the association every later open reads.
*/
@@ -70,6 +69,8 @@ export class FilePanelChoiceModel<Candidate extends FilePanelCandidate> {
private snapshot: FilePanelChoiceSnapshot<Candidate> = EMPTY;
private asked = false;
private openedLineNavigation: number | null = null;
/** A line target arrived before the file resolved; it opens the built-in once it does. */
private lineTargetPending = false;
constructor(private readonly deps: FilePanelChoiceDeps) {}
@@ -85,11 +86,17 @@ export class FilePanelChoiceModel<Candidate extends FilePanelCandidate> {
if (source.targetKey !== current.targetKey) {
this.asked = false;
this.openedLineNavigation = null;
this.lineTargetPending = false;
current = { ...EMPTY, targetKey: source.targetKey };
}
if (source.lineNavigation !== null && source.lineNavigation !== this.openedLineNavigation) {
this.openedLineNavigation = source.lineNavigation;
current = { ...current, chosenPanelId: this.deps.linePanelId, picker: null };
this.lineTargetPending = true;
}
if (this.lineTargetPending && source.resolution) {
this.lineTargetPending = false;
const builtin = source.resolution.candidates.find((candidate) => candidate.builtin);
if (builtin) current = { ...current, chosenPanelId: builtin.id, picker: null };
}
const openWith = stableOpenWith(current.openWith, openWithState(source.resolution));
let picker = openWith ? current.picker : null;
@@ -1,17 +1,12 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { Ellipsis } from "lucide-react-native";
import {
MenuItem,
MenuSeparator,
MenuSubTrigger,
type MenuPageDefinition,
} from "@/components/ui/menu";
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
import { PluginIconView } from "@/plugins/icons";
import type { Theme } from "@/styles/theme";
@@ -22,7 +17,6 @@ import { filePanelAlwaysUseLabel, filePanelIcon, filePanelOptionLabel } from "./
const OPEN_WITH_PAGE_ID = "filePanelOpenWith";
const MENU_ICON_SIZE = 16;
const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedPanelIcon = withUnistyles(PluginIconView);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -43,41 +37,6 @@ export function useFilePanelOpenWithMenu(
return useOpenWithMenu(useTabFilePanelChoice(identity), testIDBase);
}
/**
* The file bar's overflow: remembering the panel the bar's switcher shows. Renders nothing unless
* the file has more than one panel.
*/
export function FilePanelBarMenu({ choice }: { choice: FilePanelChoice }) {
const { t } = useTranslation();
const compact = useIsCompactFormFactor();
const { openWith } = useFilePanelChoiceSnapshot(choice);
if (!openWith) return null;
const label = t("panels.file.filePanels.actions");
return (
<DropdownMenu compactMode="sheet">
<ToolbarButton kind="menu" label={label} compact={compact} testID="file-panel-bar-menu">
<ThemedEllipsis
size={paneContentToolbarIconSize(compact)}
uniProps={extraMutedIconColorMapping}
/>
</ToolbarButton>
<DropdownMenuContent
align="end"
width={240}
sheetTitle={label}
testID="file-panel-bar-menu-surface"
>
<AlwaysUseItem
choice={choice}
label={filePanelAlwaysUseLabel(t, openWith.selectorKey)}
selected={openWith.remembered}
testID="file-panel-bar-menu-always-use"
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
function useOpenWithMenu(
choice: FilePanelChoice | null,
testIDBase: string,
@@ -14,7 +14,7 @@ export function filePanelIcon(panel: FilePanel): PluginIcon {
return panel.builtin ? BUILTIN_PRESENTATION[panel.id].icon : panel.contribution.icon;
}
/** "Markdown", "Rich Markdown": the panel switcher's label. */
/** "Markdown", "Rich Markdown": the panel's title. */
export function filePanelTitle(t: TFunction, panel: FilePanel): string {
return panel.builtin
? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`)
@@ -1,31 +0,0 @@
import { describe, expect, it } from "vitest";
import type { FilePanelOpenWithState } from "./model";
import { filePanelSwitcherState } from "./switcher-layout";
function openWith(count: number): FilePanelOpenWithState<{ id: string }> {
const candidates = Array.from({ length: count }, (_, index) => ({ id: `panel-${index}` }));
return { selectorKey: "ext:md", candidates, activePanelId: "panel-1", remembered: false };
}
describe("filePanelSwitcherState", () => {
it("shows nothing when the file has one panel or none", () => {
expect(filePanelSwitcherState(null, { compact: false })).toBeNull();
});
it("sets up to three panels side by side and more behind a menu", () => {
const wide = { compact: false };
expect(filePanelSwitcherState(openWith(2), wide)?.layout).toBe("segmented");
expect(filePanelSwitcherState(openWith(3), wide)?.layout).toBe("segmented");
expect(filePanelSwitcherState(openWith(4), wide)).toMatchObject({
layout: "menu",
activePanelId: "panel-1",
candidates: openWith(4).candidates,
});
});
it("sets two panels side by side on a compact screen and more behind a menu", () => {
const compact = { compact: true };
expect(filePanelSwitcherState(openWith(2), compact)?.layout).toBe("segmented");
expect(filePanelSwitcherState(openWith(3), compact)?.layout).toBe("menu");
});
});
@@ -1,24 +0,0 @@
import type { FilePanelOpenWithState } from "./model";
/** Up to this many panels sit side by side in the bar; more go behind a menu. */
const SEGMENTED_PANEL_LIMIT = { wide: 3, compact: 2 };
export interface FilePanelSwitcherState<Candidate> {
layout: "segmented" | "menu";
candidates: readonly Candidate[];
activePanelId: string;
}
/** The bar's panel switcher for a tab's choice; null when the file has one panel or none. */
export function filePanelSwitcherState<Candidate>(
openWith: FilePanelOpenWithState<Candidate> | null,
{ compact }: { compact: boolean },
): FilePanelSwitcherState<Candidate> | null {
if (!openWith) return null;
const limit = compact ? SEGMENTED_PANEL_LIMIT.compact : SEGMENTED_PANEL_LIMIT.wide;
return {
layout: openWith.candidates.length > limit ? "menu" : "segmented",
candidates: openWith.candidates,
activePanelId: openWith.activePanelId,
};
}
@@ -1,138 +0,0 @@
import { useCallback, useMemo } from "react";
import { Text } from "react-native";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { MenuItem } from "@/components/ui/menu";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
ToolbarLabelTriggerIcon,
isToolbarLabelTriggerHighlighted,
toolbarLabelTriggerStyle,
toolbarLabelTriggerTextStyle,
} from "@/components/ui/toolbar-label-trigger";
import { useIsCompactFormFactor } from "@/constants/layout";
import { PluginIconView } from "@/plugins/icons";
import type { Theme } from "@/styles/theme";
import type { FilePanel } from "../panels";
import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook";
import { filePanelIcon, filePanelTitle } from "./presentation";
import { filePanelSwitcherState, type FilePanelSwitcherState } from "./switcher-layout";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedPanelIcon = withUnistyles(PluginIconView);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** The file bar's panel switcher. Renders nothing unless the file has more than one panel. */
export function FilePanelSwitcher({ choice }: { choice: FilePanelChoice }) {
const { openWith } = useFilePanelChoiceSnapshot(choice);
const compact = useIsCompactFormFactor();
const switcher = filePanelSwitcherState(openWith, { compact });
const select = useCallback((panelId: string) => choice.openWith(panelId), [choice]);
if (!switcher) return null;
return switcher.layout === "segmented" ? (
<SegmentedSwitcher switcher={switcher} onSelect={select} />
) : (
<MenuSwitcher switcher={switcher} onSelect={select} />
);
}
interface SwitcherProps {
switcher: FilePanelSwitcherState<FilePanel>;
onSelect(panelId: string): void;
}
function SegmentedSwitcher({ switcher, onSelect }: SwitcherProps) {
const { t } = useTranslation();
const options = useMemo(
() =>
switcher.candidates.map((panel) => ({
value: panel.id,
label: filePanelTitle(t, panel),
testID: `file-panel-switcher-${panel.id}`,
})),
[switcher.candidates, t],
);
return (
<SegmentedControl
size="xs"
value={switcher.activePanelId}
onValueChange={onSelect}
options={options}
testID="file-panel-switcher"
/>
);
}
function MenuSwitcher({ switcher, onSelect }: SwitcherProps) {
const { t } = useTranslation();
const active = switcher.candidates.find((panel) => panel.id === switcher.activePanelId);
const activeTitle = active ? filePanelTitle(t, active) : "";
return (
<DropdownMenu>
<DropdownMenuTrigger
style={toolbarLabelTriggerStyle}
accessibilityRole="button"
accessibilityLabel={t("panels.file.filePanels.openWith")}
testID="file-panel-switcher"
>
{(state) => (
<>
<Text
style={toolbarLabelTriggerTextStyle(isToolbarLabelTriggerHighlighted(state))}
numberOfLines={1}
>
{activeTitle}
</Text>
<ToolbarLabelTriggerIcon>
<ThemedChevronDown size={12} uniProps={extraMutedIconColorMapping} />
</ToolbarLabelTriggerIcon>
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="file-panel-switcher-menu">
{switcher.candidates.map((panel) => (
<SwitcherMenuItem
key={panel.id}
panel={panel}
selected={panel.id === switcher.activePanelId}
onSelect={onSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function SwitcherMenuItem({
panel,
selected,
onSelect,
}: {
panel: FilePanel;
selected: boolean;
onSelect(panelId: string): void;
}) {
const { t } = useTranslation();
const handleSelect = useCallback(() => onSelect(panel.id), [onSelect, panel.id]);
const leading = useMemo(
() => <ThemedPanelIcon icon={filePanelIcon(panel)} size={16} uniProps={mutedIconMapping} />,
[panel],
);
return (
<MenuItem
selected={selected}
leading={leading}
onSelect={handleSelect}
testID={`file-panel-switcher-${panel.id}`}
>
{filePanelTitle(t, panel)}
</MenuItem>
);
}
+1 -6
View File
@@ -6,12 +6,7 @@ export {
type FilePanelChoice,
} from "./choice/hook";
export { FilePanelPicker } from "./choice/picker";
export { FilePanelSwitcher } from "./choice/switcher";
export {
FilePanelBarMenu,
useFilePanelOpenWithMenu,
type FilePanelOpenWithMenu,
} from "./choice/open-with";
export { useFilePanelOpenWithMenu, type FilePanelOpenWithMenu } from "./choice/open-with";
export { DocumentFrame } from "./document-frame";
export { filePanelIcon, filePanelOptionLabel, filePanelSelectorLabel } from "./choice/presentation";
export { useFilePanelSettings, type FilePanelSettings } from "./settings/hook";
@@ -70,6 +70,17 @@ describe("resolveFilePanel", () => {
});
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [every] }).panel).toBeNull();
});
it("offers a built-in panel claiming every file only when no other built-in claims the file", () => {
const every: FilePanelCandidate = { ...markdown, id: "builtin:source", files: "all" };
expect(resolve({ candidates: [markdown, alternative, every] }).candidates).toEqual([
markdown,
alternative,
]);
expect(resolve({ candidates: [alternative, every] }).candidates).toEqual([alternative, every]);
expect(
resolve({ file: { ...file, kind: "binary" }, candidates: [markdown, every] }).candidates,
).toEqual([]);
});
it("passes over a built-in panel for another kind of file, even with an association", () => {
expect(
resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel,
+15 -1
View File
@@ -99,6 +99,14 @@ export function claimsFilePanelSelectorKey(
: matchesName("", value, selector);
}
/**
* A built-in panel claiming every file of its kind (Source) is the floor: a candidate only for
* files no other built-in panel claims, never an alternative beside one.
*/
function isBuiltinFloor(candidate: FilePanelCandidate): boolean {
return candidate.builtin && candidate.files === "all";
}
/** Paseo asks which panel opens a file only when a plugin panel is one of several candidates. */
export function asksForFilePanel(candidates: readonly FilePanelCandidate[]): boolean {
return candidates.length > 1 && candidates.some((candidate) => !candidate.builtin);
@@ -112,11 +120,17 @@ export function resolveFilePanel<Candidate extends FilePanelCandidate>(input: {
choice?: string | null;
}): FilePanelResolution<Candidate> {
const { file, associations } = input;
const candidates = input.candidates.filter(
const matching = input.candidates.filter(
(candidate) =>
(candidate.fileKind === undefined || candidate.fileKind === file.kind) &&
matchesFiles(file, candidate.files),
);
const claimedByBuiltin = matching.some(
(candidate) => candidate.builtin && candidate.files !== "all",
);
const candidates = claimedByBuiltin
? matching.filter((candidate) => !isBuiltinFloor(candidate))
: matching;
const filename = filePanelBasename(file.path);
const matchedFilename = candidates.some((candidate) => claimsFilename(candidate.files, filename));
const selectorKey = matchedFilename
@@ -39,12 +39,12 @@ describe("buildFilePanelSettingsRows", () => {
expect(rows(catalog())).toEqual([]);
});
it("lists a key a plugin panel claims, built-in first and Source last", () => {
it("lists a key a plugin panel claims with the built-in first and no Source", () => {
const plugin = richMarkdown("host-a");
expect(rows(catalog(plugin))).toEqual([
{
selectorKey: "ext:md",
candidates: [builtinMarkdown, plugin, source],
candidates: [builtinMarkdown, plugin],
associatedPanelId: null,
},
]);
@@ -77,19 +77,25 @@ describe("buildFilePanelSettingsRows", () => {
it("offers a panel installed on several hosts once, as the first host has it", () => {
const first = richMarkdown("host-a");
const [row] = rows(catalog(first, richMarkdown("host-b")));
expect(row?.candidates).toEqual([builtinMarkdown, first, source]);
expect(row?.candidates).toEqual([builtinMarkdown, first]);
});
it("keeps a remembered key that only built-in panels claim", () => {
expect(rows(catalog(), { "ext:markdown": source.id })).toEqual([
{
selectorKey: "ext:markdown",
candidates: [builtinMarkdown, source],
candidates: [builtinMarkdown],
associatedPanelId: source.id,
},
]);
});
it("offers only Source on a text key no other panel claims", () => {
expect(rows(catalog(), { "ext:ts": source.id })).toEqual([
{ selectorKey: "ext:ts", candidates: [source], associatedPanelId: source.id },
]);
});
it("keeps a remembered key whose panel is on no connected host", () => {
expect(rows(catalog(), { "ext:pdf": "plugin:pdf-panel/pdf" })).toEqual([
{ selectorKey: "ext:pdf", candidates: [source], associatedPanelId: "plugin:pdf-panel/pdf" },
@@ -104,7 +110,7 @@ describe("buildFilePanelSettingsRows", () => {
};
const keys = rows(catalog(named)).map((row) => row.selectorKey);
expect(keys).toEqual(["name:README.md"]);
expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named, source]);
expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named]);
});
it("orders extensions before filenames, each alphabetically", () => {
@@ -40,10 +40,9 @@ export function buildFilePanelSettingsRows<Candidate extends FilePanelCandidate>
}
/**
* The panels offered under a key, by the resolver's matching rule. A panel claiming every file of
* a kind (Source) is offered unless a built-in panel claiming the key reads another kind: without a
* file, that is the only knowledge of the key's kind. A remembered panel that does not fit the file
* when it opens is ignored there.
* The panels offered under a key, by the resolver's matching rule. The built-in floor (Source) is
* offered only when no built-in panel claims the key. A remembered panel that does not fit the
* file when it opens is ignored there.
*/
function offeredCandidates<Candidate extends FilePanelCandidate>(
candidates: readonly Candidate[],
@@ -55,7 +54,7 @@ function offeredCandidates<Candidate extends FilePanelCandidate>(
);
return candidates.filter((candidate) =>
candidate.files === "all"
? !claiming.some((claimant) => claimant.builtin && claimant.fileKind !== candidate.fileKind)
? !claiming.some((claimant) => claimant.builtin)
: claiming.includes(candidate),
);
}
+104 -40
View File
@@ -1,5 +1,13 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useFileText } from "@getpaseo/plugin/client";
import {
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
type ComponentProps,
type ComponentType,
} from "react";
import { useFileText, type PluginFileLocation } from "@getpaseo/plugin/client";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
@@ -11,12 +19,12 @@ import { formatFileSize } from "@/utils/format-file-size";
import type { Theme } from "@/styles/theme";
import { FilePanelLoading } from "../loading";
import type { BuiltinFilePanelProps } from "../panels";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { FileConflictAlertState } from "./conflict-alert";
import { FileEditorModel, getFileConflictCallout } from "./editor/model";
import { FileEditorView } from "./editor/view";
import { selectSourcePresentation } from "./readonly/presentation";
import { FileSourceView } from "./readonly/view";
import { SourceStatusRow } from "./status";
import { TextFileToolbar, type TextFileView } from "./toolbar";
// CodeMirror and native source rendering consume appearance as props. Subscribe at the
// rendering leaf so theme updates reconfigure it without replacing the editor/model.
@@ -40,19 +48,49 @@ const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance);
/** The daemon refuses to write larger files. */
const MAX_EDITABLE_BYTES = 1024 * 1024;
/**
* Paseo's editor for text files. Edits files up to 1 MiB on web; shows larger files and every file
* on native read-only, and refuses files above the display budget before reading them.
*/
export function SourcePanel(props: BuiltinFilePanelProps) {
const size = props.file.size;
const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" });
if (presentation === "unsupported") return <TooLargeSource size={size} />;
if (isWeb && size <= MAX_EDITABLE_BYTES) return <EditableSource {...props} />;
return <ReadonlySource {...props} presentation={presentation} />;
/** A rendered view of the file's text: the panel's Preview, shown instead of its source. */
export interface TextFilePreviewProps {
text: string;
target: BuiltinFilePanelProps["target"];
}
function TooLargeSource({ size }: { size: number }) {
type TextFilePanelProps = BuiltinFilePanelProps & {
/** The panel's Preview; with one, the toolbar switches between Preview and Source. */
preview?: ComponentType<TextFilePreviewProps>;
};
/**
* What the Markdown, HTML and Source panels share: a toolbar over the file's Source view and, when
* the panel passes one, its Preview. Both views show the one read of the file and, while editing, the one editor buffer, so switching
* between them neither re-reads nor loses edits. Edits files up to 1 MiB on web; shows larger files
* and every file on native read-only, and refuses files above the display budget before reading.
*/
export function TextFilePanel(props: TextFilePanelProps) {
const size = props.file.size;
const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" });
if (presentation === "unsupported") return <TooLargeTextFile size={size} />;
if (isWeb && size <= MAX_EDITABLE_BYTES) return <EditableTextFile {...props} />;
return <ReadonlyTextFile {...props} presentation={presentation} />;
}
/**
* The view a panel with a preview shows. It opens on Preview, and on Source for a line target;
* each later line target switches to Source again.
*/
function useTextFileView(
location: PluginFileLocation,
): [TextFileView, (view: TextFileView) => void] {
const lineTarget = location.lineStart ? location.revision : null;
const [view, setView] = useState<TextFileView>(lineTarget === null ? "preview" : "source");
const [shownLineTarget, setShownLineTarget] = useState(lineTarget);
if (lineTarget !== null && lineTarget !== shownLineTarget) {
setShownLineTarget(lineTarget);
setView("source");
}
return [view, setView];
}
function TooLargeTextFile({ size }: { size: number }) {
const { t } = useTranslation();
return (
<View style={styles.centerState} testID="file-source-too-large">
@@ -62,14 +100,16 @@ function TooLargeSource({ size }: { size: number }) {
);
}
function ReadonlySource({
function ReadonlyTextFile({
file,
location,
target,
preview: Preview,
presentation,
}: BuiltinFilePanelProps & { presentation: "highlighted" | "plain" }) {
}: TextFilePanelProps & { presentation: "highlighted" | "plain" }) {
const text = useFileText(file);
const { t } = useTranslation();
const [view, setView] = useTextFileView(location);
const sourceLocation = useMemo(
() => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }),
[location.lineEnd, location.lineStart, target.path],
@@ -82,25 +122,35 @@ function ReadonlySource({
</View>
);
}
const showPreview = Preview !== undefined && view === "preview";
return (
<View style={styles.container}>
<ThemedFileSourceView
content={text.text}
filename={target.path}
location={sourceLocation}
navigationRevision={location.revision}
presentation={presentation}
<TextFileToolbar
size={file.size}
lineCount={text.text.split("\n").length}
view={Preview ? view : undefined}
onViewChange={Preview ? setView : undefined}
/>
<SourceStatusRow size={file.size} lineCount={text.text.split("\n").length} />
{showPreview ? (
<Preview text={text.text} target={target} />
) : (
<ThemedFileSourceView
content={text.text}
filename={target.path}
location={sourceLocation}
navigationRevision={location.revision}
presentation={presentation}
/>
)}
</View>
);
}
function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps) {
function EditableTextFile({ file, tab, location, target, preview: Preview }: TextFilePanelProps) {
const { settings } = useAppSettings();
const { t } = useTranslation();
const [model] = useState(() => new FileEditorModel({ file }));
const [editorReady, setEditorReady] = useState(false);
const [view, setView] = useTextFileView(location);
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
@@ -130,7 +180,6 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps)
}, [model, snapshot.modified, t]);
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
const handleRetry = useCallback(() => void model.reload(), [model]);
const handleReady = useCallback(() => setEditorReady(true), []);
const editorLocation = useMemo(
() => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }),
[location.lineEnd, location.lineStart, target.path],
@@ -159,11 +208,23 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps)
onOverwrite: handleOverwrite,
};
}
const showPreview = Preview !== undefined && view === "preview";
return (
<View style={styles.container}>
{conflict ? <FileConflictAlert state={conflict} /> : null}
<View style={styles.editor}>
<ThemedFileEditorView
<TextFileToolbar
size={file.size}
lineCount={snapshot.content.split("\n").length}
view={Preview ? view : undefined}
onViewChange={Preview ? setView : undefined}
editorStatus={snapshot.status}
cursor={showPreview ? undefined : cursor}
vimMode={showPreview ? null : vimMode}
conflict={conflict}
/>
{showPreview ? (
<Preview text={snapshot.content} target={target} />
) : (
<SourceEditor
model={model}
filename={target.path}
location={editorLocation}
@@ -171,17 +232,20 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps)
vimEnabled={settings.vimKeybindings}
onCursorChange={setCursor}
onVimModeChange={setVimMode}
onReady={handleReady}
/>
{editorReady ? null : <FilePanelLoading />}
</View>
<SourceStatusRow
size={file.size}
lineCount={snapshot.content.split("\n").length}
editorStatus={snapshot.status}
vimMode={vimMode}
cursor={cursor}
/>
)}
</View>
);
}
/** The editor over the panel's buffer. Each mount shows Loading until the editor shows the text. */
function SourceEditor(props: Omit<ComponentProps<typeof ThemedFileEditorView>, "onReady">) {
const [ready, setReady] = useState(false);
const handleReady = useCallback(() => setReady(true), []);
return (
<View style={styles.editor}>
<ThemedFileEditorView {...props} onReady={handleReady} />
{ready ? null : <FilePanelLoading />}
</View>
);
}
@@ -1,122 +0,0 @@
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { Theme } from "@/styles/theme";
import { formatFileSize } from "@/utils/format-file-size";
import type { FileEditorStatus } from "./editor/model";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** Source's status row: the file's size and lines, the save state, the vim mode and the cursor. */
export function SourceStatusRow({
size,
lineCount,
editorStatus,
vimMode,
cursor,
}: {
size: number;
lineCount: number;
editorStatus?: FileEditorStatus;
vimMode?: string | null;
cursor?: { line: number; column: number };
}) {
const { t } = useTranslation();
return (
<View style={styles.row} testID="file-source-status">
<View style={styles.metadata}>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{formatFileSize(size)}
</Text>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
>
{t("panels.file.editor.lines", { count: lineCount })}
</Text>
</View>
<View
style={styles.status}
accessibilityLabel={
editorStatus ? t("panels.file.editor.editorStatus", { status: editorStatus }) : undefined
}
>
{editorStatus === "dirty" ? (
<View
style={styles.dirtyDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
/>
) : null}
{editorStatus === "saving" ? (
<>
<ThemedSpinner size={14} uniProps={spinnerMapping} />
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
</>
) : null}
{editorStatus === "error" ? (
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
) : null}
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
{cursor ? (
<Text style={styles.whisper} accessibilityLabel={t("panels.file.editor.cursor", cursor)}>
Ln {cursor.line}, Col {cursor.column}
</Text>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexShrink: 0,
height: 28,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
metadata: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
status: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
dirtyDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundExtraMuted,
},
vim: {
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
},
}));
@@ -0,0 +1,163 @@
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { Theme } from "@/styles/theme";
import { formatFileSize } from "@/utils/format-file-size";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { FileEditorStatus } from "./editor/model";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export type TextFileView = "preview" | "source";
/**
* The text file panel's toolbar: the file's size and lines, the save state, the vim mode and the
* cursor, the Preview|Source control when the panel has a preview, and a conflict under it.
*/
export function TextFileToolbar({
size,
lineCount,
view,
onViewChange,
editorStatus,
cursor,
vimMode,
conflict,
}: {
size: number;
lineCount: number;
view?: TextFileView;
onViewChange?(view: TextFileView): void;
editorStatus?: FileEditorStatus;
cursor?: { line: number; column: number };
vimMode?: string | null;
conflict?: FileConflictAlertState | null;
}) {
const { t } = useTranslation();
const views = [
{
value: "preview" as const,
label: t("panels.file.editor.preview"),
testID: "file-mode-preview",
},
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
];
return (
<View style={styles.chrome}>
<PaneContentToolbar testID="file-panel-bar">
<View style={styles.row}>
<View style={styles.metadata}>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{formatFileSize(size)}
</Text>
<Text
style={styles.whisper}
numberOfLines={1}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
>
{t("panels.file.editor.lines", { count: lineCount })}
</Text>
</View>
<View
style={styles.status}
accessibilityLabel={
editorStatus
? t("panels.file.editor.editorStatus", { status: editorStatus })
: undefined
}
>
{editorStatus === "dirty" ? (
<View
style={styles.dirtyDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
/>
) : null}
{editorStatus === "saving" ? (
<>
<ThemedSpinner size={14} uniProps={spinnerMapping} />
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
</>
) : null}
{editorStatus === "error" ? (
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
) : null}
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
{cursor ? (
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
>
Ln {cursor.line}, Col {cursor.column}
</Text>
) : null}
</View>
{view && onViewChange ? (
<SegmentedControl
size="xs"
value={view}
onValueChange={onViewChange}
testID="file-preview-mode"
options={views}
/>
) : null}
</View>
</PaneContentToolbar>
{conflict ? <FileConflictAlert state={conflict} /> : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
chrome: {
flexShrink: 0,
},
row: {
height: "100%",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
},
metadata: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
status: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
dirtyDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundExtraMuted,
},
vim: {
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
},
}));
@@ -92,12 +92,11 @@ function checkoutStatusUpdate(
};
}
function selectBaseComparison(isDirtyAtSelection: boolean): void {
function selectBaseComparison(): void {
selectWorkingDiffComparison({
serverId,
cwd,
comparison: "base",
isDirty: isDirtyAtSelection,
});
}
@@ -120,14 +119,13 @@ describe("fetchCheckoutStatus", () => {
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
});
it("expires a manual working-diff comparison when the fetched dirty state flipped", async () => {
selectBaseComparison(true);
it("keeps a manual working-diff comparison when the fetched dirty state flips", async () => {
selectBaseComparison();
const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) };
await fetchCheckoutStatus({ client, serverId, cwd });
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: false })).toBe("base");
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted");
expect(resolveWorkingDiffComparison({ serverId, cwd })).toBe("base");
});
});
@@ -286,31 +284,25 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
).toBe("unauthenticated");
});
it("expires a manual working-diff comparison when the pushed dirty state flipped", () => {
const queryClient = createQueryClient();
selectBaseComparison(false);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
});
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted");
});
it("keeps a manual working-diff comparison while the pushed dirty state still matches", () => {
const queryClient = createQueryClient();
selectBaseComparison(true);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
});
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("base");
});
it.each(["base", "uncommitted"] as const)(
"keeps %s selected across dirty and clean pushes",
(comparison) => {
const queryClient = createQueryClient();
selectWorkingDiffComparison({ serverId, cwd, comparison });
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
});
expect(resolveWorkingDiffComparison({ serverId, cwd })).toBe(comparison);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ isDirty: false })),
});
expect(resolveWorkingDiffComparison({ serverId, cwd })).toBe(comparison);
},
);
it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => {
const queryClient = createQueryClient();
@@ -9,7 +9,6 @@ import {
} from "@/git/query-keys";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison";
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
@@ -18,13 +17,8 @@ export interface CheckoutStatusClient {
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
}
// Checkout status enters the app through exactly two doors: daemon pushes
// (applyCheckoutStatusUpdateFromEvent) and query fetches (fetchCheckoutStatus). Both run
// the dirty-state reactions, so they hold regardless of which screens are mounted.
export async function fetchCheckoutStatus({
client,
serverId,
cwd,
}: {
client: CheckoutStatusClient;
@@ -32,7 +26,6 @@ export async function fetchCheckoutStatus({
cwd: string;
}): Promise<CheckoutStatusPayload> {
const payload = await client.getCheckoutStatus(cwd);
expireWorkingDiffComparisons({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
return payload;
}
@@ -78,11 +71,6 @@ export function applyCheckoutStatusUpdateFromEvent({
void queryClient.invalidateQueries({
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
});
expireWorkingDiffComparisons({
serverId,
cwd: payload.cwd,
isDirty: payload.isGit && payload.isDirty,
});
if (!prStatus) {
return;
+24 -6
View File
@@ -133,7 +133,7 @@ function useDiscardChangesAction({
}: {
serverId: string;
cwd: string;
diffMode: "uncommitted" | "base";
diffMode: "uncommitted" | "base" | "all";
}): ((path: string, oldPath?: string) => void) | undefined {
const { t } = useTranslation();
const toast = useToast();
@@ -281,11 +281,12 @@ export function DiffLayoutToggle({
}
interface DiffModeMenuProps {
diffMode: "uncommitted" | "base";
diffMode: "uncommitted" | "base" | "all";
committedDescription?: string;
testIDPrefix?: string;
onSelectUncommitted: () => void;
onSelectBase: () => void;
onSelectAll: () => void;
}
export function DiffModeMenu({
@@ -294,10 +295,12 @@ export function DiffModeMenu({
testIDPrefix = "changes-diff",
onSelectUncommitted,
onSelectBase,
onSelectAll,
}: DiffModeMenuProps) {
const { t } = useTranslation();
const uncommittedLabel = t("workspace.git.diff.uncommitted");
const committedLabel = t("workspace.git.diff.committed");
const allLabel = t("workspace.git.diff.all");
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -311,7 +314,7 @@ export function DiffModeMenu({
return (
<>
<Text style={toolbarLabelTriggerTextStyle(highlighted)} numberOfLines={1}>
{diffMode === "uncommitted" ? uncommittedLabel : committedLabel}
{{ all: allLabel, uncommitted: uncommittedLabel, base: committedLabel }[diffMode]}
</Text>
<ToolbarLabelTriggerIcon>
<ThemedChevronDown size={12} uniProps={extraMutedIconColorMapping} />
@@ -321,6 +324,14 @@ export function DiffModeMenu({
}}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={260} testID={`${testIDPrefix}-status-menu`}>
<DropdownMenuItem
testID={`${testIDPrefix}-mode-all`}
selected={diffMode === "all"}
onSelect={onSelectAll}
>
{allLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID={`${testIDPrefix}-mode-uncommitted`}
selected={diffMode === "uncommitted"}
@@ -469,10 +480,11 @@ interface ChangesRepositoryToolbarModel {
interface ChangesComparisonToolbarModel {
committedDescription?: string;
diffMode: "uncommitted" | "base";
diffMode: "uncommitted" | "base" | "all";
mode: ChangesToolbarMode;
selectedDiffStat: { additions: number; deletions: number } | null;
onSelectBase: () => void;
onSelectAll: () => void;
onSelectUncommitted: () => void;
}
@@ -488,11 +500,12 @@ interface BuildChangesHeaderModelInput {
committedDescription?: string;
compact: boolean;
cwd: string;
diffMode: "uncommitted" | "base";
diffMode: "uncommitted" | "base" | "all";
gitActions: GitActions;
mode: ChangesToolbarMode;
onOpenPullRequest: () => void;
onSelectBase: () => void;
onSelectAll: () => void;
onSelectUncommitted: () => void;
pullRequest: PrHint | null;
selectedDiffStat: { additions: number; deletions: number } | null;
@@ -521,6 +534,7 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): {
mode: input.mode,
selectedDiffStat: input.selectedDiffStat,
onSelectBase: input.onSelectBase,
onSelectAll: input.onSelectAll,
onSelectUncommitted: input.onSelectUncommitted,
},
};
@@ -737,6 +751,7 @@ function ChangesComparisonToolbar({
committedDescription={model.committedDescription}
onSelectUncommitted={model.onSelectUncommitted}
onSelectBase={model.onSelectBase}
onSelectAll={model.onSelectAll}
/>
{model.selectedDiffStat ? (
<DiffStat
@@ -1155,7 +1170,7 @@ interface ChangesEmptyAction {
function computeChangesEmptyAction(input: {
hideWhitespace: boolean;
diffMode: "uncommitted" | "base";
diffMode: "uncommitted" | "base" | "all";
status: CheckoutStatusPayload | null;
seeUncommittedLabel: string;
seeCommittedLabel: string;
@@ -1544,6 +1559,7 @@ export function ChangesSurface({
diffMode,
selectUncommitted: handleSelectUncommitted,
selectBase: handleSelectBase,
selectAll: handleSelectAll,
files,
diffPayloadError,
diffTooLarge,
@@ -1897,6 +1913,7 @@ export function ChangesSurface({
mode: toolbarMode,
onOpenPullRequest: handleOpenPullRequest,
onSelectBase: handleSelectBase,
onSelectAll: handleSelectAll,
onSelectUncommitted: handleSelectUncommitted,
pullRequest: selectPrHintFromStatus(pullRequestStatus, forge),
selectedDiffStat,
@@ -1911,6 +1928,7 @@ export function ChangesSurface({
gitActions,
handleOpenPullRequest,
handleSelectBase,
handleSelectAll,
handleSelectUncommitted,
isMobile,
forge,
+1 -1
View File
@@ -24,7 +24,7 @@ export function checkoutStatusQueryKey(serverId: string, cwd: string) {
export function checkoutDiffQueryKey(
serverId: string,
cwd: string,
mode: "uncommitted" | "base",
mode: "uncommitted" | "base" | "all",
baseRef?: string,
ignoreWhitespace?: boolean,
) {
+22 -7
View File
@@ -1,4 +1,5 @@
import { shareCheckoutDiff } from "./diff-sharing";
import { useSessionStore } from "@/stores/session-store";
import { useMemo } from "react";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useReplicaQuery } from "@/data/query";
@@ -10,7 +11,7 @@ import { checkoutDiffQueryKey } from "@/git/query-keys";
interface UseCheckoutDiffQueryOptions {
serverId: string;
cwd: string;
mode: "uncommitted" | "base";
mode: "uncommitted" | "base" | "all";
baseRef?: string;
ignoreWhitespace?: boolean;
enabled?: boolean;
@@ -26,18 +27,18 @@ export type DiffLine = DiffHunk["lines"][number];
export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
function normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base";
mode: "uncommitted" | "base" | "all";
baseRef?: string;
ignoreWhitespace?: boolean;
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
}): { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean } {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: "base", ignoreWhitespace };
? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: compare.mode, ignoreWhitespace };
}
export function useCheckoutDiffQuery({
@@ -49,6 +50,11 @@ export function useCheckoutDiffQuery({
enabled = true,
queryScope,
}: UseCheckoutDiffQueryOptions) {
// COMPAT(checkoutDiffAll): added in v0.12.0, remove after 2027-04-21 once host floor includes All changes.
const allSupported = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutDiffAll === true,
);
const requiresUpdate = mode === "all" && !allSupported;
const retainedPanelActive = useRetainedPanelActive();
const queryEnabled = enabled && retainedPanelActive;
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -71,7 +77,7 @@ export function useCheckoutDiffQuery({
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
const routeEnabled = Boolean(queryEnabled && isConnected && cwd);
const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate);
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
queryKey,
@@ -91,7 +97,16 @@ export function useCheckoutDiffQuery({
}),
});
return deriveCheckoutDiffResult(query.data ?? null);
return deriveCheckoutDiffResult(
requiresUpdate && isConnected
? {
requestId: "unsupported",
cwd,
files: [],
error: { code: "UNKNOWN", message: "Update the host to view All changes" },
}
: (query.data ?? null),
);
}
export interface CheckoutDiffResult {
+2 -2
View File
@@ -45,7 +45,6 @@ export function useWorkingDiff({
status?.error?.message ??
(isStatusError && statusError instanceof Error ? statusError.message : null);
const baseRef = gitStatus?.baseRef ?? undefined;
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const currentBranchName =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
@@ -53,9 +52,9 @@ export function useWorkingDiff({
serverId,
workspaceId,
cwd,
isDirty: hasUncommittedChanges,
});
const selectUncommitted = useCallback(() => selectComparison("uncommitted"), [selectComparison]);
const selectAll = useCallback(() => selectComparison("all"), [selectComparison]);
const selectBase = useCallback(() => selectComparison("base"), [selectComparison]);
const {
@@ -111,6 +110,7 @@ export function useWorkingDiff({
diffMode,
selectUncommitted,
selectBase,
selectAll,
files,
diffPayloadError,
diffTooLarge,
@@ -1,7 +1,6 @@
import { useCallback } from "react";
import { create } from "zustand";
import {
expireWorkingDiffComparisonsInState,
resolveWorkingDiffComparisonFromState,
selectWorkingDiffComparisonInState,
type WorkingDiffCheckoutIdentity,
@@ -13,7 +12,6 @@ interface WorkingDiffComparisonStore extends WorkingDiffComparisonState {
select: (
input: WorkingDiffCheckoutIdentity & {
comparison: WorkingDiffComparison;
isDirty: boolean;
},
) => void;
}
@@ -23,21 +21,18 @@ const useWorkingDiffComparisonStore = create<WorkingDiffComparisonStore>((set) =
select: (input) => set((state) => selectWorkingDiffComparisonInState(state, input)),
}));
export function useWorkingDiffComparison(
input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
): {
export function useWorkingDiffComparison(input: WorkingDiffCheckoutIdentity): {
comparison: WorkingDiffComparison;
selectComparison: (comparison: WorkingDiffComparison) => void;
} {
const { serverId, workspaceId, cwd, isDirty } = input;
const { serverId, workspaceId, cwd } = input;
const comparison = useWorkingDiffComparisonStore((state) =>
resolveWorkingDiffComparisonFromState(state, { serverId, workspaceId, cwd, isDirty }),
resolveWorkingDiffComparisonFromState(state, { serverId, workspaceId, cwd }),
);
const select = useWorkingDiffComparisonStore((state) => state.select);
const selectComparison = useCallback(
(next: WorkingDiffComparison) =>
select({ serverId, workspaceId, cwd, isDirty, comparison: next }),
[cwd, isDirty, select, serverId, workspaceId],
(next: WorkingDiffComparison) => select({ serverId, workspaceId, cwd, comparison: next }),
[cwd, select, serverId, workspaceId],
);
return { comparison, selectComparison };
}
@@ -45,28 +40,17 @@ export function useWorkingDiffComparison(
export function selectWorkingDiffComparison(
input: WorkingDiffCheckoutIdentity & {
comparison: WorkingDiffComparison;
isDirty: boolean;
},
): void {
useWorkingDiffComparisonStore.getState().select(input);
}
export function resolveWorkingDiffComparison(
input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
input: WorkingDiffCheckoutIdentity,
): WorkingDiffComparison {
return resolveWorkingDiffComparisonFromState(useWorkingDiffComparisonStore.getState(), input);
}
export function expireWorkingDiffComparisons(input: {
serverId: string;
cwd: string;
isDirty: boolean;
}): void {
useWorkingDiffComparisonStore.setState((state) =>
expireWorkingDiffComparisonsInState(state, input),
);
}
export function resetWorkingDiffComparisons(): void {
useWorkingDiffComparisonStore.setState({ overrides: {} });
}
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
expireWorkingDiffComparisonsInState,
resolveWorkingDiffComparisonFromState,
selectWorkingDiffComparisonInState,
type WorkingDiffComparisonState,
@@ -26,79 +25,22 @@ describe("working diff comparison", () => {
);
});
it("defaults from checkout dirtiness and honors a matching manual selection", () => {
expect(
resolveWorkingDiffComparisonFromState(emptyState(), { ...checkout, isDirty: true }),
).toBe("uncommitted");
expect(
resolveWorkingDiffComparisonFromState(emptyState(), { ...checkout, isDirty: false }),
).toBe("base");
const selected = selectWorkingDiffComparisonInState(emptyState(), {
...checkout,
comparison: "base",
isDirty: true,
});
expect(resolveWorkingDiffComparisonFromState(selected, { ...checkout, isDirty: true })).toBe(
"base",
);
it.each([true, false])("defaults to All changes when checkout dirtiness is %s", (isDirty) => {
const status = { ...checkout, isDirty };
expect(resolveWorkingDiffComparisonFromState(emptyState(), status)).toBe("all");
});
it("masks and expires stale selections for every workspace on the checkout", () => {
let state = selectWorkingDiffComparisonInState(emptyState(), {
...checkout,
comparison: "base",
isDirty: true,
});
state = selectWorkingDiffComparisonInState(state, {
...checkout,
workspaceId: "workspace-2",
comparison: "uncommitted",
isDirty: false,
});
state = selectWorkingDiffComparisonInState(state, {
serverId: "server-2",
workspaceId: "workspace-3",
cwd: "/other",
comparison: "base",
isDirty: true,
});
expect(resolveWorkingDiffComparisonFromState(state, { ...checkout, isDirty: false })).toBe(
"base",
);
const expired = expireWorkingDiffComparisonsInState(state, {
serverId: checkout.serverId,
cwd: checkout.cwd,
isDirty: false,
});
expect(expired.overrides[workingDiffComparisonKey(checkout)]).toBeUndefined();
expect(
expired.overrides[workingDiffComparisonKey({ ...checkout, workspaceId: "workspace-2" })],
).toBeDefined();
expect(
expired.overrides[
workingDiffComparisonKey({
serverId: "server-2",
workspaceId: "workspace-3",
cwd: "/other",
})
],
).toBeDefined();
});
it("keeps state identity when nothing expires", () => {
const state = selectWorkingDiffComparisonInState(emptyState(), {
...checkout,
comparison: "base",
isDirty: true,
});
expect(
expireWorkingDiffComparisonsInState(state, {
serverId: checkout.serverId,
cwd: checkout.cwd,
isDirty: true,
}),
).toBe(state);
});
it.each(["all", "base", "uncommitted"] as const)(
"keeps the selected %s comparison",
(comparison) => {
const selected = selectWorkingDiffComparisonInState(emptyState(), {
...checkout,
comparison,
});
expect(resolveWorkingDiffComparisonFromState(selected, checkout)).toBe(comparison);
expect(
resolveWorkingDiffComparisonFromState(selected, { ...checkout, workspaceId: "other" }),
).toBe("all");
},
);
});
@@ -1,10 +1,7 @@
export type WorkingDiffComparison = "uncommitted" | "base";
export type WorkingDiffComparison = "uncommitted" | "base" | "all";
export interface WorkingDiffComparisonOverride {
serverId: string;
cwd: string;
comparison: WorkingDiffComparison;
isDirtyAtSelection: boolean;
}
export interface WorkingDiffComparisonState {
@@ -34,17 +31,13 @@ export function selectWorkingDiffComparisonInState(
state: WorkingDiffComparisonState,
input: WorkingDiffCheckoutIdentity & {
comparison: WorkingDiffComparison;
isDirty: boolean;
},
): WorkingDiffComparisonState {
return {
overrides: {
...state.overrides,
[workingDiffComparisonKey(input)]: {
serverId: input.serverId.trim(),
cwd: normalizeCwd(input.cwd),
comparison: input.comparison,
isDirtyAtSelection: input.isDirty,
},
},
};
@@ -52,34 +45,7 @@ export function selectWorkingDiffComparisonInState(
export function resolveWorkingDiffComparisonFromState(
state: WorkingDiffComparisonState,
input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
input: WorkingDiffCheckoutIdentity,
): WorkingDiffComparison {
const override = state.overrides[workingDiffComparisonKey(input)];
// Status can render before boundary expiry runs, so resolution must also mask a stale
// selection under any ordering of the two updates.
if (override?.isDirtyAtSelection === input.isDirty) {
return override.comparison;
}
return input.isDirty ? "uncommitted" : "base";
}
export function expireWorkingDiffComparisonsInState(
state: WorkingDiffComparisonState,
input: { serverId: string; cwd: string; isDirty: boolean },
): WorkingDiffComparisonState {
// Expire at the status boundary so selections cannot return after an unmounted surface
// misses a dirty-state transition.
const staleKeys = Object.entries(state.overrides)
.filter(
([, override]) =>
override.serverId === input.serverId.trim() &&
override.cwd === normalizeCwd(input.cwd) &&
override.isDirtyAtSelection !== input.isDirty,
)
.map(([key]) => key);
if (staleKeys.length === 0) return state;
const overrides = { ...state.overrides };
for (const key of staleKeys) delete overrides[key];
return { overrides };
return state.overrides[workingDiffComparisonKey(input)]?.comparison ?? "all";
}
+4 -2
View File
@@ -956,6 +956,7 @@ export const ar: TranslationResources = {
checkingRepository: "فحص المستودع...",
notRepository: "ليس مستودع جيت",
diffMode: "وضع الفرق",
all: "كل التغييرات",
uncommitted: "غير ملتزم",
committed: "ملتزم",
branchUnknown: "مجهول",
@@ -1927,7 +1928,6 @@ export const ar: TranslationResources = {
alwaysUseExtension: "استخدام دائمًا لـ .{{extension}}",
alwaysUseFilename: "استخدام دائمًا لـ {{filename}}",
option: "{{title}} — {{owner}}",
actions: "إجراءات الملف",
builtin: {
owner: "Paseo",
markdown: "Markdown",
@@ -1946,6 +1946,8 @@ export const ar: TranslationResources = {
notFound: "الملف غير موجود",
failedToLoadPreview: "فشل تحميل معاينة الملف",
editor: {
preview: "معاينة",
source: "المصدر",
fileSize: "حجم الملف {{size}}",
lines: "{{count}} سطر",
editorStatus: "حالة المحرر {{status}}",
@@ -2251,7 +2253,7 @@ export const ar: TranslationResources = {
automaticHint: "يختار Paseo نموذجًا سريعًا متاحًا",
preferredHint: "اختر النموذج الذي يستخدمه Paseo",
model: "النموذج",
fallbackHint: "إذا لم يكن متاحًا، يستخدم Paseo نموذجًا آخر متاحًا",
modelHint: "يُستخدم هذا النموذج فقط. إذا فشل، لا يجرّب Paseo نموذجًا آخر",
docs: "الوثائق",
saveError: "تعذر تحديث إنشاء البيانات الوصفية",
},
+4 -2
View File
@@ -964,6 +964,7 @@ export const en = {
checkingRepository: "Checking repository...",
notRepository: "Not a git repository",
diffMode: "Diff mode",
all: "All changes",
uncommitted: "Uncommitted",
committed: "Committed",
branchUnknown: "Unknown",
@@ -1952,7 +1953,6 @@ export const en = {
alwaysUseExtension: "Always use for .{{extension}}",
alwaysUseFilename: "Always use for {{filename}}",
option: "{{title}} — {{owner}}",
actions: "File actions",
builtin: {
owner: "Paseo",
markdown: "Markdown",
@@ -1971,6 +1971,8 @@ export const en = {
notFound: "File not found",
failedToLoadPreview: "Failed to load file preview",
editor: {
preview: "Preview",
source: "Source",
fileSize: "File size {{size}}",
lines: "{{count}} lines",
editorStatus: "Editor status {{status}}",
@@ -2340,7 +2342,7 @@ export const en = {
automaticHint: "Paseo picks a fast available model",
preferredHint: "Choose the model Paseo uses",
model: "Model",
fallbackHint: "If it is unavailable, Paseo falls back to another available model",
modelHint: "Only this model is used. If it fails, Paseo does not try another model",
docs: "Docs",
saveError: "Unable to update metadata generation",
},
+4 -2
View File
@@ -989,6 +989,7 @@ export const es: TranslationResources = {
checkingRepository: "Comprobando repositorio...",
notRepository: "No es un repositorio de git",
diffMode: "modo diferencial",
all: "Todos los cambios",
uncommitted: "No comprometido",
committed: "Comprometido",
branchUnknown: "Desconocido",
@@ -1975,7 +1976,6 @@ export const es: TranslationResources = {
alwaysUseExtension: "Usar siempre para .{{extension}}",
alwaysUseFilename: "Usar siempre para {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Acciones del archivo",
builtin: {
owner: "Paseo",
markdown: "Markdown",
@@ -1994,6 +1994,8 @@ export const es: TranslationResources = {
notFound: "No se encontró el archivo",
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
editor: {
preview: "Vista previa",
source: "Código fuente",
fileSize: "Tamaño {{size}}",
lines: "{{count}} líneas",
editorStatus: "Estado del editor: {{status}}",
@@ -2263,7 +2265,7 @@ export const es: TranslationResources = {
automaticHint: "Paseo elige un modelo rápido disponible",
preferredHint: "Elige el modelo que usa Paseo",
model: "Modelo",
fallbackHint: "Si no está disponible, Paseo usa otro modelo disponible",
modelHint: "Solo se usa este modelo. Si falla, Paseo no prueba otro modelo",
docs: "Documentación",
saveError: "No se pudo actualizar la generación de metadatos",
},
+4 -2
View File
@@ -982,6 +982,7 @@ export const fr: TranslationResources = {
checkingRepository: "Vérification du dépôt…",
notRepository: "Ce n’est pas un dépôt git",
diffMode: "Mode de diff",
all: "Toutes les modifications",
uncommitted: "Non commité",
committed: "Commité",
branchUnknown: "Inconnue",
@@ -1973,7 +1974,6 @@ export const fr: TranslationResources = {
alwaysUseExtension: "Toujours utiliser pour .{{extension}}",
alwaysUseFilename: "Toujours utiliser pour {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Actions du fichier",
builtin: {
owner: "Paseo",
markdown: "Markdown",
@@ -1992,6 +1992,8 @@ export const fr: TranslationResources = {
notFound: "Fichier introuvable",
failedToLoadPreview: "Impossible de charger l’aperçu du fichier",
editor: {
preview: "Aperçu",
source: "Source",
fileSize: "Taille du fichier : {{size}}",
lines: "{{count}} lignes",
editorStatus: "État de l’éditeur : {{status}}",
@@ -2263,7 +2265,7 @@ export const fr: TranslationResources = {
automaticHint: "Paseo choisit un modèle rapide disponible",
preferredHint: "Choisissez le modèle utilisé par Paseo",
model: "Modèle",
fallbackHint: "S’il est indisponible, Paseo utilise un autre modèle disponible",
modelHint: "Seul ce modèle est utilisé. S’il échoue, Paseo n’essaie pas d’autre modèle",
docs: "Documentation",
saveError: "Impossible de mettre à jour la génération de métadonnées",
},
+4 -2
View File
@@ -969,6 +969,7 @@ export const ja: TranslationResources = {
checkingRepository: "リポジトリを確認中...",
notRepository: "gitリポジトリではありません",
diffMode: "差分モード",
all: "すべての変更",
uncommitted: "未コミット",
committed: "コミット済み",
branchUnknown: "不明",
@@ -1946,7 +1947,6 @@ export const ja: TranslationResources = {
alwaysUseExtension: ".{{extension}} に常に使用",
alwaysUseFilename: "{{filename}} に常に使用",
option: "{{title}} — {{owner}}",
actions: "ファイルの操作",
builtin: {
owner: "Paseo",
markdown: "Markdown",
@@ -1965,6 +1965,8 @@ export const ja: TranslationResources = {
notFound: "ファイルが見つかりません",
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
editor: {
preview: "プレビュー",
source: "ソース",
fileSize: "ファイルサイズ {{size}}",
lines: "{{count}} 行",
editorStatus: "エディターの状態 {{status}}",
@@ -2236,7 +2238,7 @@ export const ja: TranslationResources = {
automaticHint: "Paseo が利用可能な高速モデルを選択します",
preferredHint: "Paseo が使用するモデルを選択します",
model: "モデル",
fallbackHint: "利用できない場合、Paseo は別の利用可能なモデルを使用します",
modelHint: "このモデルのみを使用します。失敗しても、Paseo は別のモデルを試しません",
docs: "ドキュメント",
saveError: "メタデータ生成を更新できません",
},
+4 -2
View File
@@ -963,6 +963,7 @@ export const ko: TranslationResources = {
checkingRepository: "저장소 확인 중...",
notRepository: "Git 저장소가 아닙니다",
diffMode: "Diff 모드",
all: "모든 변경 사항",
uncommitted: "커밋되지 않음",
committed: "커밋됨",
branchUnknown: "알 수 없음",
@@ -1937,7 +1938,6 @@ export const ko: TranslationResources = {
alwaysUseExtension: ".{{extension}}에 항상 사용",
alwaysUseFilename: "{{filename}}에 항상 사용",
option: "{{title}} — {{owner}}",
actions: "파일 작업",
builtin: {
owner: "Paseo",
markdown: "Markdown",
@@ -1956,6 +1956,8 @@ export const ko: TranslationResources = {
notFound: "파일을 찾을 수 없습니다",
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
editor: {
preview: "미리보기",
source: "소스",
fileSize: "파일 크기 {{size}}",
lines: "{{count}} 라인",
editorStatus: "편집기 상태 {{status}}",
@@ -2225,7 +2227,7 @@ export const ko: TranslationResources = {
automaticHint: "Paseo가 사용 가능한 빠른 모델을 선택합니다",
preferredHint: "Paseo에서 사용할 모델을 선택하세요",
model: "모델",
fallbackHint: "사용할 수 없으면 Paseo가 다른 사용 가능한 모델을 사용합니다",
modelHint: "이 모델만 사용합니다. 실패해도 Paseo는 다른 모델을 시도하지 않습니다",
docs: "문서",
saveError: "메타데이터 생성을 업데이트할 수 없습니다",
},
+4 -2
View File
@@ -979,6 +979,7 @@ export const ptBR: TranslationResources = {
checkingRepository: "Verificando repositório...",
notRepository: "Não é um repositório git",
diffMode: "Modo de diff",
all: "Todas as alterações",
uncommitted: "Sem commit",
committed: "Com commit",
branchUnknown: "Desconhecido",
@@ -1960,7 +1961,6 @@ export const ptBR: TranslationResources = {
alwaysUseExtension: "Sempre usar para .{{extension}}",
alwaysUseFilename: "Sempre usar para {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Ações do arquivo",
builtin: {
owner: "Paseo",
markdown: "Markdown",
@@ -1979,6 +1979,8 @@ export const ptBR: TranslationResources = {
notFound: "Arquivo não encontrado",
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
editor: {
preview: "Prévia",
source: "Fonte",
fileSize: "Tamanho {{size}}",
lines: "{{count}} linhas",
editorStatus: "Status do editor: {{status}}",
@@ -2248,7 +2250,7 @@ export const ptBR: TranslationResources = {
automaticHint: "O Paseo escolhe um modelo rápido disponível",
preferredHint: "Escolha o modelo que o Paseo usa",
model: "Modelo",
fallbackHint: "Se ele não estiver disponível, o Paseo usa outro modelo disponível",
modelHint: "Somente este modelo é usado. Se ele falhar, o Paseo não tenta outro modelo",
docs: "Documentação",
saveError: "Não foi possível atualizar a geração de metadados",
},
+5 -2
View File
@@ -973,6 +973,7 @@ export const ru: TranslationResources = {
checkingRepository: "Проверяем репозиторий...",
notRepository: "Это не репозиторий Git",
diffMode: "Режим диффа",
all: "Все изменения",
uncommitted: "Незафиксированные изменения",
committed: "Зафиксированные изменения",
branchUnknown: "Неизвестная ветка",
@@ -1959,7 +1960,6 @@ export const ru: TranslationResources = {
alwaysUseExtension: "Всегда использовать для .{{extension}}",
alwaysUseFilename: "Всегда использовать для {{filename}}",
option: "{{title}} — {{owner}}",
actions: "Действия с файлом",
builtin: {
owner: "Paseo",
markdown: "Markdown",
@@ -1978,6 +1978,8 @@ export const ru: TranslationResources = {
notFound: "Файл не найден",
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
editor: {
preview: "Предпросмотр",
source: "Исходный текст",
fileSize: "Размер файла {{size}}",
lines: "Строк: {{count}}",
editorStatus: "Состояние редактора: {{status}}",
@@ -2248,7 +2250,8 @@ export const ru: TranslationResources = {
automaticHint: "Paseo выбирает быструю доступную модель",
preferredHint: "Выберите модель, которую использует Paseo",
model: "Модель",
fallbackHint: "Если она недоступна, Paseo использует другую доступную модель",
modelHint:
"Используется только эта модель. Если она не сработает, Paseo не будет пробовать другую модель",
docs: "Документация",
saveError: "Не удалось обновить настройки генерации метаданных",
},
+4 -2
View File
@@ -948,6 +948,7 @@ export const zhCN: TranslationResources = {
checkingRepository: "正在检查 repository...",
notRepository: "不是 git repository",
diffMode: "Diff 模式",
all: "全部更改",
uncommitted: "未 commit",
committed: "已 commit",
branchUnknown: "未知",
@@ -1905,7 +1906,6 @@ export const zhCN: TranslationResources = {
alwaysUseExtension: "始终用于 .{{extension}}",
alwaysUseFilename: "始终用于 {{filename}}",
option: "{{title}} — {{owner}}",
actions: "文件操作",
builtin: {
owner: "Paseo",
markdown: "Markdown",
@@ -1924,6 +1924,8 @@ export const zhCN: TranslationResources = {
notFound: "找不到文件",
failedToLoadPreview: "加载文件预览失败",
editor: {
preview: "预览",
source: "源代码",
fileSize: "文件大小 {{size}}",
lines: "{{count}} 行",
editorStatus: "编辑器状态 {{status}}",
@@ -2192,7 +2194,7 @@ export const zhCN: TranslationResources = {
automaticHint: "Paseo 会选择一个可用的快速模型",
preferredHint: "选择 Paseo 使用的模型",
model: "模型",
fallbackHint: "如果不可用,Paseo 会改用其他可用模型",
modelHint: "只使用此模型。如果失败,Paseo 不会尝试其他模型",
docs: "文档",
saveError: "无法更新元数据生成设置",
},
+5 -4
View File
@@ -141,7 +141,7 @@ describe("evaluatePluginClientBundle", () => {
Reflect.deleteProperty(globalThis, "__pluginRemovals");
});
it("collects timeline transformers and renderers", () => {
it.each(["tool_call", "notification"])("collects %s transformers and renderers", (itemType) => {
const plugin = evaluatePluginClientBundle(
"reports",
bundle(`
@@ -149,7 +149,7 @@ describe("evaluatePluginClientBundle", () => {
const schema = { safeParse(value) { return { success: true, data: value }; } };
plugin.addTimelineTransformer({
id: "test-report",
query: { itemType: "tool_call" },
query: { itemType: "${itemType}" },
transform() { return { items: [] }; },
});
plugin.addTimelineRenderer({
@@ -162,7 +162,7 @@ describe("evaluatePluginClientBundle", () => {
);
expect(plugin.timelineTransformers.map(({ id, query }) => ({ id, query }))).toEqual([
{ id: "test-report", query: { itemType: "tool_call" } },
{ id: "test-report", query: { itemType } },
]);
expect(plugin.timelineRenderers.map(({ kind, version }) => ({ kind, version }))).toEqual([
{ kind: "test-report", version: 1 },
@@ -695,7 +695,8 @@ describe("evaluatePluginClientBundle", () => {
`(function(require) {
const shared = require("@getpaseo/plugin");
const client = require("@getpaseo/plugin/client");
const { ExternalLink, Loading } = require("@getpaseo/plugin/client/ui");
const { ExternalLink, Loading, Markdown } = require("@getpaseo/plugin/client/ui");
if (typeof Markdown !== "function") throw new Error("Markdown");
if (typeof ExternalLink !== "function") throw new Error("ExternalLink");
if (typeof Loading !== "function") throw new Error("Loading");
for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace", "openExternalUrl", "useFileText", "useFileBytes", "useFileUrl", "PluginFileConflictError", "PluginFileDeletedError"]) {
+1
View File
@@ -47,6 +47,7 @@ const TIMELINE_ITEM_TYPES = new Set([
"reasoning",
"tool_call",
"todo",
"notification",
"error",
"compaction",
]);
@@ -0,0 +1,6 @@
import type { MarkdownProps } from "@getpaseo/plugin/client/ui";
import { MarkdownRenderer } from "@/components/markdown/renderer";
export function Markdown({ children }: MarkdownProps) {
return children ? <MarkdownRenderer text={children} /> : null;
}
@@ -26,3 +26,5 @@ export { TextInput } from "./text-input";
export { copyText } from "./clipboard";
export { useToast } from "./toast";
export { StreamedText } from "./streamed-text";
export { Markdown } from "./markdown";
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
export type ReviewDraftMode = "uncommitted" | "base";
export type ReviewDraftMode = "uncommitted" | "base" | "all";
export type ReviewDraftSide = "old" | "new";
export interface ReviewDraftComment {
@@ -136,6 +136,7 @@ class FakeDaemonClient {
if (response) await response;
const failure = this.sendAgentMessageFailures.shift();
if (failure) throw failure;
return {};
}
async waitForSentMessages(count: number): Promise<void> {
@@ -1010,6 +1011,50 @@ describe("HostRuntimeController", () => {
expect(activeClient.latencyMeasurements()).toEqual([]);
});
it.each([12, null])(
"falls back to relay when the direct client disconnects with heartbeat RTT %s",
async (heartbeatRtt) => {
useHostRuntimeClock();
const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host: makeHost(),
deps: makeDeps(latencies, clients),
});
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
expect(controller.getSnapshot().connectionStatus).toBe("online");
const directClient = clients[0];
directClient.heartbeatReportsRtt(heartbeatRtt);
directClient.clearLatencyMeasurements();
latencies["direct:lan:6767"] = new Error("LAN unreachable");
directClient.setConnectionState({ status: "disconnected", reason: "network changed" });
expect(controller.getSnapshot().connectionStatus).toBe("error");
expect(directClient.getLastLivenessRttMs()).toBe(heartbeatRtt);
await vi.advanceTimersByTimeAsync(2_000);
await controller.runProbeCycleNow();
const snapshot = controller.getSnapshot();
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
expect(snapshot.connectionStatus).toBe("online");
expect(snapshot.probeByConnectionId).toEqual(
new Map([
["direct:lan:6767", { status: "unavailable", latencyMs: null }],
["relay:relay.paseo.sh:443", { status: "available", latencyMs: 65 }],
]),
);
expect(directClient.isDisposed()).toBe(true);
expect(directClient.latencyMeasurements()).toEqual([]);
await controller.stop();
},
);
it("does not create a probe client for the selected connection while it reconnects", async () => {
useHostRuntimeClock();
const relay: HostConnection = {
+8
View File
@@ -1110,6 +1110,14 @@ export class HostRuntimeController {
shouldCloseClient = shouldCloseClient && !activated;
if (activeClient) {
if (activeClient.getConnectionState().status !== "connected") {
probeByConnectionId.set(connection.id, {
status: "unavailable",
latencyMs: null,
});
publishProbeState();
return;
}
const rttMs = activeClient.getLastLivenessRttMs();
if (!this.isCurrentProbeRequest(requestVersion)) {
return;
@@ -160,7 +160,8 @@ function useRefRenderOption(itemById: Map<string, PickerItem>, isPending: boolea
/**
* The New workspace setup fields, one trigger per field, each opening its picker. Compact: a stack
* of full-width fields with the label inside. Desktop: one meta row of badges. Either way, a field
* that does not apply to the selected project is left out, and Host only shows with a second host.
* that does not apply to the selected project is left out. Host always shows, even with one host, so
* its Add host action keeps multi-host discoverable.
*/
export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {
const { t } = useTranslation();
@@ -170,7 +171,6 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement
const isolationPicker = useIsolationPicker(isolation, isPending);
const renderRefOption = useRefRenderOption(base.itemById, isPending);
const showHost = host.allHosts.length > 1;
const selectedHostLabel =
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
const selectedProject = project.selectedProject;
@@ -214,7 +214,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement
testID="new-workspace-project-picker-trigger"
/>
);
const hostField = showHost ? (
const hostField = (
<HostPicker
hosts={host.allHosts}
value={host.selectedServerId}
@@ -222,8 +222,9 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement
open={host.openState}
onOpenChange={host.onOpenChange}
anchorRef={host.anchorRef}
includeAddHost={isCompact}
includeAddHost
onAddHost={host.onAddHost}
addHostTestID="new-workspace-host-picker-add-host"
searchable={false}
title={t("newWorkspace.fields.host")}
desktopPlacement="bottom-start"
@@ -242,7 +243,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement
testID="host-picker-trigger"
/>
</HostPicker>
) : null;
);
const isolationField = isolation.canCreateWorktree ? (
<FieldTrigger
ref={isolation.anchorRef}
@@ -349,7 +350,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement
return (
<View testID="new-workspace-ref-picker-row" style={styles.desktopRow} pointerEvents="box-none">
<View style={styles.desktopControl}>{projectField}</View>
{hostField ? <View style={styles.desktopControl}>{hostField}</View> : null}
<View style={styles.desktopControl}>{hostField}</View>
{isolationField ? <View style={styles.desktopControl}>{isolationField}</View> : null}
{baseField ? <View style={styles.desktopControl}>{baseField}</View> : null}
<View style={styles.launchSpacer} pointerEvents="none" />
@@ -139,7 +139,7 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) {
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.metadataGeneration.model")}</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.metadataGeneration.fallbackHint")}
{t("settings.metadataGeneration.modelHint")}
</Text>
</View>
<CombinedModelSelector
@@ -10,6 +10,7 @@ interface UseWorkspaceTabLayoutInput {
tabLabelWidths: number[];
viewportWidthOverride?: number | null;
metrics: WorkspaceTabLayoutMetrics;
compact: boolean;
}
interface UseWorkspaceTabLayoutResult {
@@ -31,8 +32,9 @@ export function useWorkspaceTabLayout(
viewportWidth: resolvedViewportWidth,
tabLabelWidths: input.tabLabelWidths,
metrics: input.metrics,
compact: input.compact,
}),
[input.metrics, input.tabLabelWidths, resolvedViewportWidth],
[input.compact, input.metrics, input.tabLabelWidths, resolvedViewportWidth],
);
return {
@@ -10,7 +10,7 @@ import React, {
type Dispatch,
type SetStateAction,
} from "react";
import { Pressable, Text, View, type LayoutChangeEvent } from "react-native";
import { Pressable, Text, View, type LayoutChangeEvent, type ScrollView } from "react-native";
import { useFilePanelOpenWithMenu } from "@/file-panels";
import {
CopyX,
@@ -68,6 +68,10 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import type { PaneHost } from "@/panels/panel-manifest";
import type { WorkspaceTabLaunchPurpose } from "@/workspace-tabs/launcher";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import {
resolveWorkspaceTabChipBackdrop,
type WorkspaceTabRailSurface,
} from "@/screens/workspace/workspace-tab-chip-backdrop";
import type { Theme } from "@/styles/theme";
import { RenderProfile } from "@/utils/render-profiler";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
@@ -659,18 +663,6 @@ function useMiddleClickClose(onClose: () => void) {
return ref;
}
/** The chip fill the running-status ring has to knock out of. Mirrors `styles.tab*` exactly. */
function resolveChipBackdrop({
isActiveFocused,
isFilled,
}: {
isActiveFocused: boolean;
isFilled: boolean;
}): SurfaceBackdrop {
if (isActiveFocused) return "surface2";
return isFilled ? "surface1" : "surface0";
}
function TabHandleContent({
presentation,
isHighlighted,
@@ -730,6 +722,7 @@ function TabChip({
resolvedTabWidth,
showLabel,
showCloseButton,
rail,
isCloseHovered,
isClosingTab,
presentation,
@@ -750,6 +743,8 @@ function TabChip({
resolvedTabWidth: number;
showLabel: boolean;
showCloseButton: boolean;
/** The surface the row paints, so chip fills and their knockouts match the host. */
rail: WorkspaceTabRailSurface;
isCloseHovered: boolean;
isClosingTab: boolean;
presentation: WorkspaceTabPresentation;
@@ -777,9 +772,11 @@ function TabChip({
const isActiveFocused = isActive && isFocused;
const isHovered = hovered || isCloseHovered;
const isHighlighted = isActiveFocused || isHovered;
const chipBackdrop: SurfaceBackdrop = resolveChipBackdrop({
const isFilled = isActive || isHovered;
const chipBackdrop: SurfaceBackdrop = resolveWorkspaceTabChipBackdrop({
rail,
isActiveFocused,
isFilled: isActive || isHovered,
isFilled,
});
const showCloseControl = showCloseButton && (isHovered || isNative || isCompact || isClosingTab);
const closeButtonDragBlockers = isWeb
@@ -796,9 +793,7 @@ function TabChip({
const tabChipStyle = useCallback(
() => [
styles.tab,
isActiveFocused && styles.tabActive,
isActive && !isFocused && styles.tabActiveUnfocused,
!isActive && isHovered && styles.tabHovered,
isFilled && styles.tabFill(chipBackdrop),
isWeb && isDragging && ({ cursor: "grabbing" } as object),
{
minWidth: resolvedTabWidth,
@@ -806,7 +801,7 @@ function TabChip({
maxWidth: resolvedTabWidth,
},
],
[isActive, isActiveFocused, isDragging, isFocused, isHovered, resolvedTabWidth],
[chipBackdrop, isDragging, isFilled, resolvedTabWidth],
);
const handleTabPointerEnter = useCallback(() => {
@@ -1058,8 +1053,10 @@ function ResolvedWorkspaceDesktopTabsRow({
onExitFocusMode,
}: ResolvedWorkspaceDesktopTabsRowProps) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const newTabKeys = useShortcutKeys("workspace-tab-new");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const tabsScrollRef = useRef<ScrollView>(null);
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
const tabScrollBoundary = useHorizontalScrollBoundary();
const [labelMeasurements, setLabelMeasurements] = useState(
@@ -1221,7 +1218,15 @@ function ResolvedWorkspaceDesktopTabsRow({
tabLabelWidths: trackSnapshot?.labelWidths ?? [],
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
metrics: layoutMetrics,
compact: isCompact,
});
// A scroll view keeps its offset when its content shrinks under the viewport, so a row that
// stops scrolling would leave the tabs it had panned past out of view with no way back.
useEffect(() => {
if (!layout.requiresHorizontalScrollFallback) {
tabsScrollRef.current?.scrollTo({ x: 0, animated: false });
}
}, [layout.requiresHorizontalScrollFallback]);
const handleDragEnd = useCallback(
(nextTabs: ResolvedWorkspaceDesktopTabRowItem[]) => {
@@ -1266,6 +1271,9 @@ function ResolvedWorkspaceDesktopTabsRow({
handle: handleNewTabKeyboardAction,
});
const rail: WorkspaceTabRailSurface = host === "explorer" ? "sidebar" : "surface";
const onSidebar = rail === "sidebar";
const renderTab = useCallback(
({
item,
@@ -1306,6 +1314,7 @@ function ResolvedWorkspaceDesktopTabsRow({
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={shouldShowCloseButton}
rail={rail}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
@@ -1334,6 +1343,7 @@ function ResolvedWorkspaceDesktopTabsRow({
onNavigateTab,
onReloadAgent,
onRenameTab,
rail,
setHoveredCloseTabKey,
tabMenuLabels,
tabDropPreviewIndex,
@@ -1353,7 +1363,7 @@ function ResolvedWorkspaceDesktopTabsRow({
const row = (
<View
style={styles.tabsContainer}
style={[styles.tabsContainer, onSidebar && styles.tabsContainerSidebar]}
testID="workspace-tabs-row"
onLayout={handleTabsContainerLayout}
>
@@ -1379,6 +1389,7 @@ function ResolvedWorkspaceDesktopTabsRow({
/>
<View style={styles.tabsScrollContainer}>
<Animated.ScrollView
ref={tabsScrollRef}
horizontal
scrollEnabled={layout.requiresHorizontalScrollFallback}
testID="workspace-tabs-scroll"
@@ -1415,7 +1426,7 @@ function ResolvedWorkspaceDesktopTabsRow({
</Animated.ScrollView>
<HorizontalScrollBoundaryShades
visible={layout.requiresHorizontalScrollFallback}
backdrop="surface"
backdrop={rail}
testIDPrefix="workspace-tabs-scroll-shade"
leftStyle={tabScrollBoundary.leftShadeStyle}
rightStyle={tabScrollBoundary.rightShadeStyle}
@@ -1461,6 +1472,7 @@ function ResolvedDesktopTabChip({
resolvedTabWidth,
showLabel,
showCloseButton,
rail,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
@@ -1488,6 +1500,7 @@ function ResolvedDesktopTabChip({
resolvedTabWidth: number;
showLabel: boolean;
showCloseButton: boolean;
rail: WorkspaceTabRailSurface;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
@@ -1560,6 +1573,7 @@ function ResolvedDesktopTabChip({
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={showCloseButton}
rail={rail}
isCloseHovered={item.isCloseHovered}
isClosingTab={item.isClosingTab}
presentation={presentation}
@@ -1589,6 +1603,9 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
overflow: "visible",
},
tabsContainerSidebar: {
backgroundColor: theme.colors.surfaceSidebar,
},
tabsScroll: {
minWidth: 0,
},
@@ -1638,15 +1655,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
userSelect: "none",
},
tabHovered: {
backgroundColor: theme.colors.surface1,
},
tabActive: {
backgroundColor: theme.colors.surface2,
},
tabActiveUnfocused: {
backgroundColor: theme.colors.surface1,
},
tabFill: (backdrop: SurfaceBackdrop) => ({
backgroundColor: theme.colors[backdrop],
}),
tabHoverFrame: {
position: "relative",
},
@@ -2,6 +2,7 @@ import { PanelRight } from "lucide-react-native";
import { type StyleProp, type ViewStyle } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import {
extraMutedIconColorMapping,
iconButtonChromeGlyphSize,
@@ -93,5 +94,16 @@ export function WorkspaceExplorerSidebarToggle({
...toggleProps
}: DesktopWorkspaceExplorerToggleProps) {
if (owner === "header") return null;
if (owner === "mobile") {
// The compact Explorer is an overlay like the compact agent list, so it closes with the same X.
return (
<SidebarCloseButton
label={toggleProps.label}
onPress={toggleProps.onPress}
testID="workspace-explorer-toggle"
accessibilityState={toggleProps.accessibilityState}
/>
);
}
return <WorkspaceExplorerToggle {...toggleProps} mobile={false} />;
}
@@ -0,0 +1,30 @@
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
/** The surface a tab rail paints: a pane header or the Explorer sidebar. */
export type WorkspaceTabRailSurface = "surface" | "sidebar";
/**
* The fill of a tab chip, named as the surface token it paints so the chip style and everything
* knocking out of it (the running-status ring, the trailing-action scrim) agree.
*
* Each rail climbs its own ladder: resting, filled (hovered, or active in an unfocused pane),
* selected. The sidebar rail uses the sidebar hover/selected tokens rather than the plain surface
* steps because light themes paint `surfaceSidebar` with `surface2`, so a `surface2` chip vanishes
* on it.
*/
export function resolveWorkspaceTabChipBackdrop({
rail,
isActiveFocused,
isFilled,
}: {
rail: WorkspaceTabRailSurface;
isActiveFocused: boolean;
isFilled: boolean;
}): SurfaceBackdrop {
if (rail === "sidebar") {
if (isActiveFocused) return "surfaceSidebarSelected";
return isFilled ? "surfaceSidebarHover" : "surfaceSidebar";
}
if (isActiveFocused) return "surface2";
return isFilled ? "surface1" : "surface0";
}
@@ -3,6 +3,9 @@ import {
WorkspaceExplorerSidebarToggle,
} from "./workspace-explorer-toggle";
import { describe, expect, it } from "vitest";
import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import { resolveWorkspaceTabChipBackdrop } from "@/screens/workspace/workspace-tab-chip-backdrop";
import { darkPureBlackTheme, darkTheme, lightTheme } from "@/styles/theme";
import {
computeWorkspaceTabLayout,
retainWorkspaceTabMeasuredWidth,
@@ -27,6 +30,7 @@ describe("computeWorkspaceTabLayout", () => {
viewportWidth: 1200,
tabLabelWidths: [56, 70, 49],
metrics,
compact: false,
});
expect(result.closeButtonPolicy).toBe("all");
@@ -41,6 +45,7 @@ describe("computeWorkspaceTabLayout", () => {
viewportWidth: 300,
tabLabelWidths: [29, 52],
metrics: { ...metrics, actionsReservedWidth: 36 },
compact: false,
});
expect(result.items).toEqual([
{ width: 64, showLabel: true },
@@ -54,6 +59,7 @@ describe("computeWorkspaceTabLayout", () => {
viewportWidth: 1200,
tabLabelWidths: [105],
metrics,
compact: false,
});
expect(result.requiresHorizontalScrollFallback).toBe(false);
@@ -65,6 +71,7 @@ describe("computeWorkspaceTabLayout", () => {
viewportWidth: 460,
tabLabelWidths: [168, 84, 56],
metrics,
compact: false,
});
expect(result.closeButtonPolicy).toBe("all");
@@ -83,6 +90,7 @@ describe("computeWorkspaceTabLayout", () => {
rowPaddingHorizontal: 0,
tabGap: 0,
},
compact: false,
});
expect(result.closeButtonPolicy).toBe("all");
@@ -95,6 +103,7 @@ describe("computeWorkspaceTabLayout", () => {
viewportWidth: 404,
tabLabelWidths: [98, 98, 98, 98],
metrics,
compact: false,
});
expect(result.closeButtonPolicy).toBe("all");
@@ -103,16 +112,43 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.items.every((item) => item.showLabel)).toBe(true);
});
it("uses horizontal scroll rather than shrinking below the clickable minimum", () => {
it("scrolls at natural widths rather than shrinking below the clickable minimum", () => {
const result = computeWorkspaceTabLayout({
viewportWidth: 403,
tabLabelWidths: [98, 98, 98, 98],
metrics,
compact: false,
});
expect(result.closeButtonPolicy).toBe("all");
expect(result.requiresHorizontalScrollFallback).toBe(true);
expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]);
expect(result.items.map((item) => item.width)).toEqual([132, 132, 132, 132]);
expect(result.items.every((item) => item.showLabel)).toBe(true);
});
it("keeps natural widths and no inline close on compact layouts while the tabs fit", () => {
const result = computeWorkspaceTabLayout({
viewportWidth: 600,
tabLabelWidths: [56, 70, 49],
metrics,
compact: true,
});
expect(result.closeButtonPolicy).toBe("none");
expect(result.requiresHorizontalScrollFallback).toBe(false);
expect(result.items.map((item) => item.width)).toEqual([90, 104, 83]);
});
it("scrolls compact layouts as soon as natural widths overflow instead of squeezing labels", () => {
const result = computeWorkspaceTabLayout({
viewportWidth: 460,
tabLabelWidths: [168, 84, 56],
metrics,
compact: true,
});
expect(result.requiresHorizontalScrollFallback).toBe(true);
expect(result.items.map((item) => item.width)).toEqual([160, 118, 90]);
expect(result.items.every((item) => item.showLabel)).toBe(true);
});
@@ -121,6 +157,7 @@ describe("computeWorkspaceTabLayout", () => {
viewportWidth: 1200,
tabLabelWidths: [],
metrics,
compact: false,
});
expect(result.closeButtonPolicy).toBe("all");
@@ -133,6 +170,7 @@ describe("computeWorkspaceTabLayout", () => {
viewportWidth: 1200,
tabLabelWidths: [68, 104],
metrics,
compact: false,
});
expect(result.items.map((item) => item.width)).toEqual([102, 138]);
@@ -169,4 +207,52 @@ describe("Explorer toggle ownership", () => {
expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner })).not.toBeNull();
}
});
it("closes the compact overlay with the same X as the compact agent list", () => {
expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner: "mobile" })?.type).toBe(
SidebarCloseButton,
);
expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner: "window" })?.type).not.toBe(
SidebarCloseButton,
);
});
});
describe("resolveWorkspaceTabChipBackdrop", () => {
const themes = { light: lightTheme, dark: darkTheme, pureBlack: darkPureBlackTheme };
const rails = {
sidebar: "surfaceSidebar",
surface: "surface0",
} as const;
it("paints the active chip in a colour the rail does not already use, in every theme", () => {
for (const [, theme] of Object.entries(themes)) {
for (const [rail, resting] of Object.entries(rails) as Array<
[keyof typeof rails, (typeof rails)[keyof typeof rails]]
>) {
const active = resolveWorkspaceTabChipBackdrop({
rail,
isActiveFocused: true,
isFilled: true,
});
const filled = resolveWorkspaceTabChipBackdrop({
rail,
isActiveFocused: false,
isFilled: true,
});
expect(theme.colors[active]).not.toBe(theme.colors[resting]);
expect(theme.colors[filled]).not.toBe(theme.colors[resting]);
expect(theme.colors[active]).not.toBe(theme.colors[filled]);
}
}
});
it("rests on the rail surface when the chip is neither active nor hovered", () => {
expect(
resolveWorkspaceTabChipBackdrop({ rail: "sidebar", isActiveFocused: false, isFilled: false }),
).toBe("surfaceSidebar");
expect(
resolveWorkspaceTabChipBackdrop({ rail: "surface", isActiveFocused: false, isFilled: false }),
).toBe("surface0");
});
});
@@ -1,4 +1,4 @@
export type WorkspaceTabCloseButtonPolicy = "all";
export type WorkspaceTabCloseButtonPolicy = "all" | "none";
export interface WorkspaceTabLayoutMetrics {
rowHorizontalInset: number;
@@ -17,6 +17,12 @@ export interface WorkspaceTabLayoutInput {
viewportWidth: number;
tabLabelWidths: number[];
metrics: WorkspaceTabLayoutMetrics;
/**
* Compact layouts are touch-first. Tabs keep their natural width and the row scrolls as soon as
* they overflow, so a label is never squeezed to make room; chips carry no inline close because a
* narrow X beside the label is easy to hit by accident and the tab's long-press menu closes it.
*/
compact: boolean;
}
export interface WorkspaceTabLayoutItem {
@@ -54,10 +60,11 @@ export function computeWorkspaceTabLayout(
input: WorkspaceTabLayoutInput,
): WorkspaceTabLayoutResult {
const tabCount = input.tabLabelWidths.length;
const closeButtonPolicy: WorkspaceTabCloseButtonPolicy = input.compact ? "none" : "all";
if (tabCount === 0) {
return {
items: [],
closeButtonPolicy: "all",
closeButtonPolicy,
requiresHorizontalScrollFallback: false,
};
}
@@ -79,12 +86,19 @@ export function computeWorkspaceTabLayout(
);
const naturalTotalWidth = naturalWidths.reduce((total, width) => total + width, 0);
const minimumTotalWidth = input.metrics.minTabWidth * tabCount;
const requiresHorizontalScrollFallback = availableTabsWidth < minimumTotalWidth;
// A row that scrolls has no reason to truncate: the tabs keep their natural widths and the
// viewport pans over them. Pointer layouts first shrink toward the clickable minimum and scroll
// only once even that does not fit; compact layouts skip the shrink.
const requiresHorizontalScrollFallback = input.compact
? naturalTotalWidth > availableTabsWidth
: availableTabsWidth < minimumTotalWidth;
let resolvedWidths = naturalWidths;
if (requiresHorizontalScrollFallback) {
resolvedWidths = Array.from({ length: tabCount }, () => input.metrics.minTabWidth);
} else if (naturalTotalWidth > availableTabsWidth) {
if (
!requiresHorizontalScrollFallback &&
!input.compact &&
naturalTotalWidth > availableTabsWidth
) {
const widthToRemove = naturalTotalWidth - availableTabsWidth;
const shrinkCapacity = naturalTotalWidth - minimumTotalWidth;
const shrinkRatio = widthToRemove / shrinkCapacity;
@@ -102,7 +116,7 @@ export function computeWorkspaceTabLayout(
width,
showLabel: width > tabChromeWidth,
})),
closeButtonPolicy: "all",
closeButtonPolicy,
requiresHorizontalScrollFallback,
};
}
@@ -1192,7 +1192,10 @@ export function restoreWorkspaceLayout(
const normalized = normalizeLayout(layout);
let root = asInternalNode(normalized.root);
let focusedPaneId = normalized.focusedPaneId;
if (!collectAllPanes(root).some((pane) => pane.id !== explorerSidebarPaneId)) {
const visibleOrdinaryPane = collectAllPanes(root).find(
(pane) => pane.id !== explorerSidebarPaneId,
);
if (!visibleOrdinaryPane) {
const ordinaryPane = findOrdinaryPane(root, explorerSidebarPaneId);
if (ordinaryPane) {
root = updatePaneInTree(root, {
@@ -1214,6 +1217,9 @@ export function restoreWorkspaceLayout(
sizes: [0.78, 0.22],
});
}
} else if (focusedPaneId === null) {
// Parked focus has an in-memory restoration record that cannot survive reload.
focusedPaneId = visibleOrdinaryPane.id;
}
return {
...normalized,
@@ -1,7 +1,13 @@
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
import { expect, it } from "vitest";
import type { StateStorage } from "zustand/middleware";
import { createWorkspaceLayoutStore } from "./workspace-layout-store";
import {
createWorkspaceLayoutStore,
collectAllTabs,
selectExplorerSidebarPaneId,
} from "./workspace-layout-store";
import { deriveWorkspacePaneState } from "../screens/workspace/workspace-pane-state";
import { selectVisibleAgentIds } from "../screens/workspace/visible-agent-ids";
Object.assign(globalThis, { indexedDB, IDBKeyRange });
@@ -88,3 +94,60 @@ it("keeps the saved layout when the shell publishes its layout mode before hydra
saved.getState().layoutByWorkspace[workspaceKey],
);
});
it.each([1, 2])(
"restores the agent pane after parked focus in storage version %i",
async (version) => {
const values = new Map<string, string>();
const storage: StateStorage = {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => {
values.set(key, value);
},
removeItem: (key) => {
values.delete(key);
},
};
const key = "host:workspace";
const source = createWorkspaceLayoutStore(undefined, storage);
await source.persist.rehydrate();
source.getState().setLayoutMode("compact");
source.getState().openTab({
workspaceKey: key,
target: { kind: "agent", agentId: "agent-a" },
intent: "reveal",
});
source.getState().unfocusPane(key);
expect(source.getState().layoutByWorkspace[key].focusedPaneId).toBeNull();
const persisted = JSON.parse(values.get("workspace-layout-state")!);
persisted.version = version;
values.set("workspace-layout-state", JSON.stringify(persisted));
const restored = createWorkspaceLayoutStore(undefined, storage);
restored.getState().setLayoutMode("compact");
await restored.persist.rehydrate();
restored.getState().reconcileTabs(key, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-a"],
autoOpenAgentIds: ["agent-a"],
knownTerminalIds: [],
standaloneTerminalIds: [],
});
const layout = restored.getState().layoutByWorkspace[key];
const tabs = collectAllTabs(layout.root);
const restoredExplorerId = selectExplorerSidebarPaneId(restored.getState(), key);
const main = deriveWorkspacePaneState({ layout, tabs });
const visible = selectVisibleAgentIds({
layout,
tabs,
routeFocused: true,
focusedPaneOnly: true,
explorerPaneId: restoredExplorerId,
explorerVisible: false,
});
expect(layout.focusedPaneId).toBe("main");
expect(main.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-a" });
expect(main.tabs.map((t) => t.descriptor.kind)).toEqual(["agent"]);
expect(visible).toEqual(["agent-a"]);
},
);
@@ -10,6 +10,7 @@ import { useRouter, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import { Globe, SquarePen, SquareTerminal } from "lucide-react-native";
import invariant from "tiny-invariant";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { pluginIconComponent } from "@/plugins/icons";
import { useInstalledPlugins } from "@/plugins/registry";
@@ -107,6 +108,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
invariant(launcher, "NewTabLauncherProvider is required");
const { config } = useDaemonConfig(serverId);
const plugins = useInstalledPlugins();
const isCompact = useIsCompactFormFactor();
ensurePanelsRegistered();
const launchSelection = useCallback(
@@ -159,7 +161,8 @@ export function useWorkspaceTabLaunchCatalog(input: {
shortcutActionId: "workspace-tab-target-changes",
disabled: false,
panelKind: "working_diff",
hidden: !launcher.showChanges,
// Compact Changes shows each file's diff inline, so a separate Diff panel is redundant.
hidden: !launcher.showChanges || isCompact,
launch: launchSelection(BUILT_IN_SELECTIONS.diff),
},
files: {
@@ -252,6 +255,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
}, [
config?.terminalProfiles,
editTerminalProfiles,
isCompact,
launchSelection,
launcher,
plugins,
@@ -1,6 +1,9 @@
import { lightHighlightColors } from "@getpaseo/highlight";
const testTheme = {
colorScheme: "light",
colors: {
syntax: lightHighlightColors,
foreground: "#111111",
foregroundMuted: "#666666",
statusSuccess: "#15803d",
+1
View File
@@ -104,6 +104,7 @@ export default defineConfig({
deps: {
fallbackCJS: true,
inline: [
"react-native-enriched-markdown",
"zustand",
"@tanstack/react-query",
"react-native-web",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/server": "0.12.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/server": "0.13.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+7 -4
View File
@@ -14,7 +14,7 @@ import { extname, resolve } from "node:path";
/** Result type for agent send command */
export interface AgentSendResult {
agentId: string;
status: "sent" | "completed" | "timeout" | "permission" | "error";
status: "queued" | "sent" | "completed" | "timeout" | "permission" | "error";
message: string;
}
@@ -201,7 +201,7 @@ export async function runSendCommand(
const sourceAgentId = process.env.PASEO_AGENT_ID?.trim() || undefined;
// Send the message
await client.sendAgentMessage(agentIdArg, promptInput, {
const receipt = await client.sendAgentMessage(agentIdArg, promptInput, {
images,
sourceAgentId,
activeTurnBehavior: options.behavior ?? (sourceAgentId ? "steer" : "interrupt"),
@@ -215,8 +215,11 @@ export async function runSendCommand(
type: "single",
data: {
agentId: agentIdArg,
status: "sent",
message: "Message sent, not waiting for completion",
status: receipt.deliveryStatus ?? "sent",
message:
receipt.deliveryStatus === "queued"
? "Message queued for delivery after two quiet seconds"
: "Message sent, not waiting for completion",
},
schema: agentSendSchema,
};
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+33 -18
View File
@@ -435,6 +435,8 @@ export interface DaemonClientTrace {
export interface SendMessageOptions {
sourceAgentId?: string;
sourceAgentServerId?: string;
sessionMode?: string;
messageId?: string;
/** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */
activeTurnBehavior?: ActiveTurnBehavior;
@@ -3732,26 +3734,34 @@ export class DaemonClient {
async sendAgentMessage(
agentId: string,
text: string,
options?: SendMessageOptions,
): Promise<void> {
options: SendMessageOptions = {},
): Promise<{ deliveryStatus?: "queued" }> {
if (
options?.sourceAgentId &&
options.sourceAgentId &&
this.lastServerInfoMessage?.features?.agentMessageProvenance !== true
) {
throw new Error("Update the Paseo host to send messages with agent provenance.");
}
if (
options.sourceAgentServerId &&
this.lastServerInfoMessage?.features?.agentMessageBatching !== true
) {
throw new Error("Update the Paseo host to send messages with a qualified agent address.");
}
const requestId = this.createRequestId();
const messageId = options?.messageId ?? crypto.randomUUID();
const messageId = options.messageId ?? crypto.randomUUID();
const message = SessionInboundMessageSchema.parse({
type: "send_agent_message_request",
requestId,
agentId,
text,
sourceAgentId: options?.sourceAgentId,
sourceAgentId: options.sourceAgentId,
sourceAgentServerId: options.sourceAgentServerId,
sessionMode: options.sessionMode,
...(messageId ? { messageId } : {}),
...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}),
...(options?.images ? { images: options.images } : {}),
...(options?.attachments ? { attachments: options.attachments } : {}),
activeTurnBehavior: options.activeTurnBehavior,
images: options.images,
attachments: options.attachments,
});
const payload = await this.sendRequest({
requestId,
@@ -3770,10 +3780,15 @@ export class DaemonClient {
if (!payload.accepted) {
throw new Error(payload.error ?? "sendAgentMessage rejected");
}
return payload.deliveryStatus ? { deliveryStatus: payload.deliveryStatus } : {};
}
async sendMessage(agentId: string, text: string, options?: SendMessageOptions): Promise<void> {
await this.sendAgentMessage(agentId, text, options);
async sendMessage(
agentId: string,
text: string,
options?: SendMessageOptions,
): Promise<{ deliveryStatus?: "queued" }> {
return this.sendAgentMessage(agentId, text, options);
}
async rewindAgent(
@@ -4380,10 +4395,10 @@ export class DaemonClient {
}
private normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base";
mode: "uncommitted" | "base" | "all";
baseRef?: string;
ignoreWhitespace?: boolean;
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
}): { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean } {
if (compare.mode === "uncommitted") {
return compare.ignoreWhitespace === true
? { mode: "uncommitted", ignoreWhitespace: true }
@@ -4392,17 +4407,17 @@ export class DaemonClient {
const trimmedBaseRef = compare.baseRef?.trim();
if (!trimmedBaseRef) {
return compare.ignoreWhitespace === true
? { mode: "base", ignoreWhitespace: true }
: { mode: "base" };
? { mode: compare.mode, ignoreWhitespace: true }
: { mode: compare.mode };
}
return compare.ignoreWhitespace === true
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace: true }
: { mode: "base", baseRef: trimmedBaseRef };
? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace: true }
: { mode: compare.mode, baseRef: trimmedBaseRef };
}
async getCheckoutDiff(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean },
requestId?: string,
): Promise<CheckoutDiffPayload> {
return this.sendCorrelatedSessionRequest({
@@ -4418,7 +4433,7 @@ export class DaemonClient {
observeCheckoutDiff(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean },
options?: { requestId?: string; signal?: AbortSignal },
): OwnedSubscription<SubscribeCheckoutDiffPayload> {
return this.observe(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.12.0",
"version": "0.13.0-beta.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

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