Compare commits

..
15 Commits
Author SHA1 Message Date
paseo-ai[bot] d972f67c3f fix: update lockfile signatures and Nix hash [skip ci] 2026-10-22 13:09:27 +00:00
Mohamed Boudra 55a7590952 Sort sidebar workspaces and keep active overflow visible (#7635)
Order ordinary sidebar workspaces by Created or Last prompt, preserve project and pin ordering, and keep later active or unacknowledged work visible beyond the first seven rows. Preserve reveal state through live updates and verify sorting, visibility, compatibility, and rendering behavior.
2026-10-22 19:59:28 +07:00
Mohamed Boudra 793b16c0df fix(relay): protect sessions against replayed commands (#7610)
Protect relay sessions against replayed commands with authenticated Noise NK negotiation, ordered records, and identity-bound security pins. Preserve legacy admission during migration and add daemon enforcement policy.

All 21 CI checks passed. General engineering review is complete; specialist cryptographic review of the handwritten Noise implementation and static-key reuse remains required before release or deployment.

Closes #359
2026-10-22 19:50:06 +07:00
Mohamed Boudra 91d749f82c fix(server): preserve workspace PR status across reconnects (#7638)
Workspace runtime snapshots lived inside refcounted watch targets and vanished when the last session disconnected. Keep the last observed git and forge data in daemon memory so reconnecting clients receive it before refresh completes, while watch lifetime continues to govern refresh only.
2026-10-22 19:48:34 +07:00
Mohamed Boudra 6376f2bf40 Remove the WebView terminal renderer: xterm on web, native grid on mobile (#7639)
* refactor(terminal): remove the WebView terminal renderer

Native uses the native grid renderer only; web keeps xterm. The legacy
renderer setting, its diagnostics toggle, the esbuild WebView bundle and
its CI drift check are gone.

* test(terminal): target the visible terminal surface in the file paste spec

A second page opening a terminal in the same workspace adds a retained,
hidden surface to the first page, so the unscoped locator matched two
textareas and failed strict mode.

* test(server): wait for the shortstat warm instead of polling 500ms

The background warm shells out to git; on Windows CI that regularly
outlasts the 20x25ms poll and fails the test. Use the completion callback
the warm already exposes and keep the non-blocking check.
2026-10-22 19:47:28 +07:00
Gerard BalaoroandMohamed Boudra 9d908d678b feat(terminal): support image paste into agent TUIs, local and remote (#3795)
* feat(terminal): support image paste into agent TUIs, local and remote (#3725)

Codex and OpenCode receive image pastes by binding Ctrl+V (\x16) and
reading the OS clipboard of the machine they run on; no image bytes
travel through the pty. Paseo's terminal swallowed that keystroke and
only ever pasted text, so image paste silently did nothing.

- web/Electron runtime: forward \x16 when the clipboard holds no text,
  keep the existing text-paste path otherwise (bash quoted-insert stays
  intact)
- new terminal.clipboard.write_image RPC ships client clipboard bytes to
  the daemon, which writes them onto its host clipboard (osascript /
  wl-copy / xclip / Set-Clipboard) so remote clients work like local
  ones; gated by server_info.features.terminalClipboardImage with a
  bare-\x16 fallback for older daemons
- clipboard tools are waited on via their exit event, not stream close:
  wl-copy and xclip fork a selection owner that inherits our pipes, so
  waiting for stream closure hung every Linux write until timeout
- headless hosts without a clipboard get a temp-file fallback: the
  daemon materializes the image under os.tmpdir() and returns its path;
  the client injects it as bracket-pasted text
- all four renderers covered (web DOM, Electron, native grid, WebView
  bridge); mobile Paste button enables for image clipboards and toasts
  failures in all 10 locales

* fix(terminal): bind image paste to private retained files

* fix(terminal): recover interrupted image metadata safely

* fix(terminal): retire image files with background lifecycle maintenance

* fix(terminal): check live paste modes and refresh native bridge

* fix(terminal): order image paste input and isolate failures

* fix(terminal): normalize paste and release failed creation reservations

* fix(terminal): validate missing Windows launch paths before spawn

* fix(terminal): keep input live during cancellable image uploads

* test(terminal): follow streamed input-mode updates

* feat(terminal): upload dropped images through the paste flow

* feat(terminal): paste and drop images through file uploads

Terminal image paste and drop now reuse the existing upload path
(client.uploadFile -> file.upload.request -> FileUploadStore) and paste
the quoted host path through the pane's input, bracketed when the app
asked for it. This replaces the PR's daemon-side image store, RPCs,
paste permits and file lifecycle, so the daemon, protocol and client
diffs for this feature are empty.

- terminal/image-paste owns a per-pane serial queue: gesture order,
  keystrokes stay live, Ctrl+C and pane/connection teardown cancel
  pending pastes, and a cancelled path is never pasted.
- Clipboard text wins over an image; any image/* type is accepted up
  to the composer's 50MB upload limit.
- Host paths are quoted for the host OS by path shape.
- One failure toast; i18n keeps two keys.
- Browser e2e covers two panes pasting distinct PNGs, drop, ordering,
  Ctrl+C cancel and disconnect against a real daemon.

Co-authored-by: Gerard Balaoro <miogerard@yahoo.com>

* fix(terminal): translate the disconnected image upload error

* feat(terminal): report image uploads through toasts

Show a lasting "Uploading image…" toast while an image uploads, replace
it with "Image uploaded" or a failure toast that carries the reason, and
dismiss it when Ctrl+C cancels the upload. Toast show now returns a dismiss
scoped to that toast, so the pane can clear its own toast without
removing a newer one.

* build(terminal): regenerate the WebView HTML for the toast events

* fix(terminal): keep i18n out of the terminal WebView bundle

The image paste queue imported i18n for its size-limit message, which pulled
every translation into the WebView HTML. The queue now reports a typed
TerminalImageTooLargeError and the pane words it.

* refactor(terminal): route dropped files by host locality and share the drop overlay

A dropped file pastes its own path only when the host is the desktop app's
own daemon (useIsLocalDaemon); anywhere else the host cannot read the
client's disk, so the file uploads through client.uploadFile and the host
path is pasted. File type no longer plays a part: images and other files
take the same route, and clipboard images always upload because they have
no path.

The terminal pane now sits in the shared FileDropZone, so dragging files
over it shows the agent panels' "Drop files here" overlay. The emulator's
own drag listeners, blue tint and 180 ms timeout are gone.
FileDropSink.onFiles is optional so a sink can take raw files without
persisting raster images as attachments.

terminal/image-paste becomes terminal/file-paste; the queue, callbacks,
toasts and i18n keys say "file".

* test(terminal): keep the fake desktop bridge in an e2e support helper

The CI contract test forbids browser specs from installing paseoDesktop
directly; the local-host drop scenario now imports it from support.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-22 17:40:01 +07:00
4ef4a95a42 Keep Claude session listing from loading whole histories into memory (#7626)
* fix(claude): stream and cache session import descriptors

* 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

* 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

* 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

* 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>

* 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.

---------

Co-authored-by: Josh Lane <joshlane@easypost.com>
Co-authored-by: Josh Lane <lane.joshlane@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-22 12:07:39 +02:00
Mohamed Boudra c3c6a936a4 List plugin header buttons in the workspace menu on phones and narrow windows (#7630)
* fix(app): list plugin header buttons in the compact workspace menu

The compact header rewrite left plugin header buttons with no home, so
phones and narrow windows had no way to reach them. They are now rows of
the compact workspace menu, with menus and popovers as pages of its sheet,
the way workspace scripts moved there.

The header overflow and the workspace menu share one row/page builder.
Each row and page resolves its own plugin installation's runtime: the old
overflow routed every row through the first entry's host client.

Closes #7601

* test(app): wait for agent control rows before keying the Command Center

Two Command Center agent-control tests typed a query and pressed Enter or
ArrowDown immediately. The agent's mode and model rows register after the
panel opens, so on a slow runner the keys landed while only file search
had matched and the mode never changed. The CI trace for the failure shows
no Mode or Model rows at the moment of the key press. The tests now wait
until both groups are listed, which keeps what they check: the best match
wins, and a stale arrow-key selection is dropped.
2026-10-22 16:32:10 +07:00
Mohamed BoudraandKassam Khoja 12e7a448f0 fix(app): open notification agents once in their current workspace (#7612)
* fix(app): open notification agents once in their current workspace

Keep tap ownership across Activity and React remounts, clear native responses, and resolve agent workspaces through existing navigation after startup settles.

Co-authored-by: Kassam Khoja <kassam@shopwrk.com>

* fix(app): hydrate agent targets with the compact sidebar closed

Hold directory demand for the agent route while resolving its workspace; the compact sidebar does not supply demand before it opens.

* fix(app): keep deferred workspace opens in the layout model

Key pending tab opens by workspace so redirects and retained screens cannot lose or consume another workspace's target. Legacy open URLs seed this state at their route boundary.

* docs(app): document notification tap ownership and native replay

* fix(ci): route feature-owned Electron sources to desktop checks

* refactor(app): separate notification API from React integration

* fix(app): consume pending opens before painting the restored tab

A legacy open link showed the previously focused terminal for a frame before switching to the requested terminal. Seed and consume the pending open in layout effects so a ready target switches before paint.

* fix(e2e): focus the visible retained terminal surface

---------

Co-authored-by: Kassam Khoja <kassam@shopwrk.com>
2026-10-22 14:41:38 +07: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
284 changed files with 11095 additions and 4490 deletions
+1
View File
@@ -41,6 +41,7 @@ server:
desktop:
- "packages/desktop/**"
- "packages/app/src/desktop/**"
- "packages/app/src/**/*.electron.{ts,tsx}"
- "packages/server/src/server/browser-tools/**"
- "packages/app/e2e/support/**"
- "packages/app/*config.{cjs,js,ts}"
+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 项修复。
+2
View File
@@ -36,6 +36,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
@@ -43,6 +44,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
| [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) | Relay E2EE modes: Noise NK mapping, record framing, negotiation, the `requireProtectedSecurity` flag, close codes |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
+5 -5
View File
@@ -21,8 +21,8 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds.
3. When the phone connects via the relay, it generates fresh ephemeral keys and sends an `e2ee_hello` message. The daemon will not process any application messages until this handshake completes.
4. Updated peers run a Noise `NK` handshake (`Noise_NK_25519_ChaChaPoly_SHA256`) keyed to the daemon's static key: the `e2ee_hello` carries the first Noise message and the daemon's `e2ee_ready` carries the second. Every later frame is a counter-nonced ChaCha20-Poly1305 record. When either side predates this, the session falls back to the legacy construction: a Curve25519 ECDH shared key with XSalsa20-Poly1305 (NaCl `box`) and a random `[24-byte nonce][ciphertext]` bundle. Both modes negotiate `binaryCiphertext`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) has the exact mapping.
The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.
@@ -34,9 +34,9 @@ The daemon requires a valid cryptographic handshake before processing any comman
- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
- **Read your traffic** — After the handshake every message is encrypted: ChaCha20-Poly1305 in protected mode, XSalsa20-Poly1305 (NaCl box) in legacy mode
- **Forge messages** — Both constructions are authenticated encryption; a tampered message is rejected in either mode
- **Replay old messages** — In protected mode the daemon contributes a fresh ephemeral key to every session and every record carries a strictly increasing counter, so a recorded session cannot be replayed into a new one and a duplicated, reordered, or reflected record closes the session. Legacy mode has neither property: it uses random nonces, and a daemon that still admits legacy clients accepts a recorded legacy transcript (hello, credentials, commands) as a new session. Set `daemon.relay.requireProtectedSecurity: true` to refuse legacy clients once every client you use has updated.
### Trust model
+9 -2
View File
@@ -74,6 +74,13 @@ server to capability-aware sessions. It deliberately does not use the broad recu
watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do
not retain non-Git directories.
Workspace git and forge runtime data belongs to one daemon-level, directory-keyed store.
Workspace descriptors, checkout diff subscriptions, and script metadata read the last known
snapshot from that store, so reconnecting clients receive it before refresh completes.
Refcounted git watches feed the store through snapshot-updated events; their lifetime controls
refresh only. Session cleanup and workspace archive retain the data. Removing the final active
workspace for a directory clears it. The store lives in memory and resets on daemon restart.
**Key modules:**
| Module | Responsibility |
@@ -175,9 +182,9 @@ Communicates with the daemon via the same WebSocket protocol as the app.
Enables remote access when the daemon is behind a firewall.
- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305
- Updated peers negotiate a Noise `NK` session with per-record counters; peers on either side that predate it fall back to the legacy NaCl `box` construction. [docs/relay-endpoint-security.md](relay-endpoint-security.md) owns the mapping, negotiation, and the `daemon.relay.requireProtectedSecurity` flag
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Client and daemon channels with identical API (`openClientSecureChannel`, `openDaemonSecureChannel`)
- Pairing via QR code transfers the daemon's public key to the client
- New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
+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:
+16 -9
View File
@@ -104,17 +104,24 @@ Do not collapse this topology with a catch-all, `getId`, or
## Agent Targets
Notifications and agent URLs enter the router with different authoritative
targets.
Notifications and agent URLs name a host and agent. Both use
`navigateToAgent()`, which reads the agent's current workspace from the store
or routes through `/h/[serverId]/agent/[agentId]`. The agent route waits for
the named host, demands its directory, and waits for workspace and layout
hydration before opening the tab. Keep the stable agent URL free of a workspace
hint. [Notifications](notifications.md) owns tap delivery and native lifecycle
constraints.
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
directly to the workspace with the agent open intent.
- Agent URLs carry only `serverId` and `agentId`. Route them through
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
the agent's workspace from the host, and then opens the agent there.
When a workspace target cannot open yet, `navigateToWorkspace()` records a
pending open in the layout model under that host and workspace key. The owning
workspace screen consumes it once after hydration. Archived-workspace recovery
reads the same pending target, so startup redirects cannot discard it and a
retained screen cannot consume another workspace's request.
Both paths converge on `navigateToAgent()`. Do not make notification routing
guess a workspace, and do not add a workspace to the stable agent URL format.
Legacy `?open=` links seed this model state at the matched route boundary and
then lose the query parameter. Internal navigation does not use the query as a
handoff. Read matched route params locally; global search params let retained
workspace screens observe requests for other workspaces.
## Params
+1
View File
@@ -26,6 +26,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
+71
View File
@@ -0,0 +1,71 @@
# Notifications
Use `notification-open` for OS notification taps. It owns delivery, parsing,
process-local deduplication, startup readiness, and native acknowledgement.
Keep agent and workspace navigation in their existing navigation modules.
Import the pure API from `@/notification-open` and the app-shell component from
`@/notification-open/react`; senders must not load the React routing runtime.
Agent payloads name a host and agent. Ignore their workspace hint when opening:
the agent can move after a notification is sent. Use `navigateToAgent`, which
uses the current store record or the agent route to resolve the workspace from
the host. Terminal payloads name their workspace and use `navigateToWorkspace`.
See [agent targets and deferred opens](expo-router.md#agent-targets) for route
ownership and workspace recovery. Notification generation, presence, and push
eligibility remain daemon concerns.
## Native delivery
Keep the router instance at module scope. Android's `singleTask` Activity can
finish on Back while its JS context remains alive. A React ref is lost when the
root remounts; process-local handled IDs must survive that remount.
Clear `lastNotificationResponse` after dispatch. Reading a launch response does
not acknowledge it. Without clearing it, a later Activity launch can reopen the
old agent.
With expo-notifications 57, Android also replays its pending response list to
new native listeners. Clearing the last response does not drain that list.
Deduplication covers listener registration within one JS context. A development
JS reload creates another context and can replay a cold-launch response; do not
add persisted handled IDs to compensate. Recheck this limitation when upgrading
expo-notifications.
iOS drains pending responses when its delegate first handles them. Push data
read by Expo arrives under `userInfo.body`; simulator payloads must match:
```json
{
"aps": { "alert": { "title": "Agent finished", "body": "Open the agent" }, "sound": "default" },
"body": {
"serverId": "host",
"workspaceId": "workspace",
"agentId": "agent",
"reason": "finished"
}
}
```
An Android FCM launch intent needs `google.message_id` and a JSON `body` extra.
An arbitrary Activity launch with data extras does not exercise Expo's FCM path.
Foreground pushes remain in Notification Center without a banner or sound.
This preserves notifications sent with stale presence information.
## Startup and verification
Wait for the root navigator, host registry, remembered selection, and startup
redirects before handing off a tap. A compact agent route must demand the host's
directory itself; its sidebar can be closed for the entire cold launch.
Verify warm and cold taps, stale and missing workspace hints, two distinct taps,
and Android Back followed by launcher reopen or a different notification tap. Check the focused tab and every
workspace's saved layout, since a foreign pinned tab survives navigation.
Use the daemon's payload shape on both iOS and Android; a browser event alone
does not test native replay or acknowledgement.
Existing foreign pins belong to the reconciliation fix in
[#6900](https://github.com/getpaseo/paseo/issues/6900). Desktop main-process window
selection is tracked separately in
[#6875](https://github.com/getpaseo/paseo/issues/6875).
+11 -6
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.
@@ -585,11 +587,14 @@ the author API, selection, `DocumentFrame`, and the version requirement. The exa
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/reference.md#header-buttons)
owns the author API and placement rules. Keep presentation policy in this module so another
placement can reuse behavior without copying registration or action state.
placement can reuse behavior without copying registration or action state. The header overflow
and the compact workspace menu share one builder that turns buttons into menu rows and pages; the
workspace menu reaches it through `usePluginWorkspaceMenu`.
Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state,
query, and toast providers inside the surface content, including overflow pages from different
plugins. Providers only around the trigger do not reach those bodies.
query, and toast providers inside the surface content. A host menu can hold buttons from several
installations, so each of its rows and pages resolves its own installation's runtime. Providers
only around the trigger do not reach those bodies.
Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned
`subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon
+88
View File
@@ -0,0 +1,88 @@
# Relay endpoint security
The relay carries daemon sessions between a client and a daemon that cannot reach each other directly. The relay is untrusted, so the two endpoints secure the session themselves. This doc owns the protected construction, its mapping onto Paseo's handshake and record flow, the negotiation rules, and the enforcement flag. The threat model lives in [SECURITY.md](../SECURITY.md); the code is `packages/relay/src/secure-channel.ts`.
Fixes [#359](https://github.com/getpaseo/paseo/issues/359): the legacy construction has no replay protection. A recorded legacy hello plus records replays onto a fresh daemon channel, and duplicated records are delivered twice.
## Two modes
| Mode | Construction | Replay protection |
| ----------- | ------------------------------------------------ | -------------------------------- |
| `legacy` | X25519 + NaCl `box`, random nonces | none |
| `protected` | `Noise_NK_25519_ChaChaPoly_SHA256` (revision 34) | counters, fresh daemon ephemeral |
Two updated peers always negotiate `protected`. An old peer on either side falls to `legacy` while the daemon permits it. Everything in `packages/relay/src/encrypted-channel.ts` is the legacy construction and is unchanged.
The shared entry points are `openClientSecureChannel` and `openDaemonSecureChannel`. Callers get a `SecureChannel` and ask it `securityMode()`; nothing outside the module branches on the construction.
## Noise mapping
- **Pattern:** NK. The daemon's static key is the existing `$PASEO_HOME/daemon-keypair.json` X25519 key that pairing already distributes. There is no second daemon identity.
- **Prologue:** the ASCII bytes `paseo-relay-e2ee/1`.
- **Primitives:** `@noble/curves` x25519, `@noble/ciphers` ChaCha20-Poly1305, `@noble/hashes` SHA-256 and HMAC. The state machine in `packages/relay/src/noise/` is our own implementation of the spec and is checked against vendored third-party vectors in `noise/fixtures/`.
- **Message 1** (`-> e, es`) travels inside the legacy hello: `{ type: "e2ee_hello", key, capabilities, protected: { v: 1, message: <base64> } }`. `key` is a separate legacy ephemeral so an old daemon still completes a legacy handshake. The Noise payload is the offered parameters.
- **Message 2** (`<- e, ee`) travels inside the ready: `{ type: "e2ee_ready", protected: { v: 1, message: <base64> } }`. Its payload is the selected parameters. The outer `capabilities` field is ignored in protected mode because it is not authenticated.
- **Parameters payload:** two bytes, `[0x01 version][flags]`. Flag bit 0 is `binaryCiphertext`. Unknown versions, reserved flag bits, and a selection the client did not offer fail the handshake.
- **Split:** the initiator sends with the first cipher state and receives with the second. The daemon is the mirror. Nonces are 64-bit little-endian in a 12-byte nonce, reserved synchronously before any await, never wrapped. A receiver advances its counter only after the tag verifies.
Nothing else is bound into the handshake. The outer JSON, the relay URL, and session credentials are not authenticated by Noise. Credentials travel in the application `hello` after message 2 verified the daemon.
## Records
Each application frame becomes one record of one or more Noise transport messages:
```
chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data
wire record = (u16 BE ciphertextLength || ciphertext)+
```
`kind` is `0x00` text or `0x01` binary. `flags` bit 0 marks the first chunk and bit 1 the last. `data` is at most 65513 bytes per chunk, so a chunk never exceeds the Noise message limit. The chunk count is `max(1, ceil(totalLength / 65513))`.
The receiver rejects a record whose metadata disagrees between chunks, whose non-final chunks are not full, whose byte total differs from `totalLength`, or that has zero chunks, trailing bytes, reserved flags, or an invalid kind. Partial plaintext never reaches the application. Transport AD is empty, as the Noise spec writes it; framing authenticity comes from the metadata being inside the ciphertext. `packages/relay/src/protected-records.ts` owns this.
Frame kind is preserved on the wire the same way as legacy: text records are base64 WebSocket text, binary records are WebSocket binary when `binaryCiphertext` was negotiated.
## Retries and failures
- The client resends its hello every second until a ready arrives. The daemon caches the exact hello text and ready text for the channel. An identical hello gets the identical ready and counters keep running. A different hello closes the channel (`1008`, transcript mismatch). A ready after the channel is open is ignored.
- A fresh daemon channel always generates a fresh `e`, so a recorded hello and its records fail authentication when replayed later, including after a daemon restart.
- A present but invalid `protected` field fails the handshake. There is no fallback to legacy once protected was attempted.
- Any record failure (bad tag, duplicate, reordered, reflected, malformed) closes the channel and nothing from that record is delivered. The daemon closes with `1011`; the client closes with `4003`, because a browser `WebSocket.close` accepts only `1000` and `3000`-`4999`.
- A record header is checked against the bytes actually received before any buffer is allocated for it, so a header claiming gigabytes costs nothing.
- Keys are cleared on close as a best effort.
## Negotiation and admission
The daemon advertises `server_info.features.relayProtectedSecurity: true` on every session, whatever mode that session negotiated. A relay client that ended up in `legacy` with such a daemon closes with `4002` before the client marks itself connected, restores subscriptions, or flushes queued commands. The check lives in the relay E2EE transport, so it also covers the Node connector; direct connections never see it.
The app pins `minimumSecurityMode: "protected"` on every relay connection to a daemon public key after the first protected handshake with that key, or when the pairing offer carries `minimumSecurityMode`. A pinned client passes `minimumMode: "protected"` to the handshake and refuses a legacy ready before sending credentials. The pin belongs to the daemon identity, not to a relay address: re-importing the same key through any relay keeps it, a different key is a different connection, and connection failures never clear it.
The handshake result is read in one place, `HostRuntimeController` when the host's active client reports connected. Every client reaches that point, whether the controller created it or adopted it from a pairing or startup probe, so no client constructor carries a persistence callback. A probe that negotiated protected but was never adopted pins nothing; the pin lands when the host first goes online.
Within one client process the floor also rises on its own. `DaemonClient` and the Node host connector remember a protected result and require it on every later attempt, so a reconnect after a relay drop cannot be answered in legacy mode even before the pin is persisted.
The daemon refuses a legacy client with close `1008` and reason `protected endpoint security required`, and logs `relay_e2ee_handshake_rejected`. That close code stops at the relay: both relays replace a daemon-side close with their own (the wrangler relay sends `1012 Server disconnected`), so an old client sees an ordinary disconnect and keeps retrying. The only client-detectable failure is the client's own `4002`, which stops reconnecting and tells the user to update.
The client learns its own `4002` and `4003` from the relay E2EE transport, which reports the close the moment the channel decides it. A socket close event arrives later and the channel's error callback earlier; neither is what `DaemonClient` acts on, because acting on the error would start a reconnect before the code is known.
## Enforcement flag
`daemon.relay.requireProtectedSecurity` in `config.json` (also `relay.requireProtectedSecurity` through the config RPC) makes the daemon refuse legacy relay clients. Default `false`. It is a local daemon setting with no relay-server counterpart.
- It is read when each hello arrives, so toggling it affects the next admission without a restart. Sessions already admitted in legacy mode stay up until they disconnect; turning the flag on does not close them. To finish enforcement, restart the daemon or disconnect its relay sessions after enabling the flag: every reconnect is then a new admission and legacy clients are refused.
- While it is on, pairing offers carry `minimumSecurityMode: "protected"` and `relay://` URIs carry `security=protected`, so new clients pin before their first handshake.
- Direct and local connections are outside the flag.
Flipping the default is a separate decision, tentatively reviewed about 30 days after the compatibility release. Nothing in the code expires on a date.
## Known limitation
While a daemon accepts legacy clients, a transcript recorded from any legacy session (hello, credentials, commands) can still be replayed to it. Pinning on the client does not change that, because the attacker replays the old client's transcript, not the pinned client's. The only remedy is enabling the flag on that daemon.
## Testing
- `packages/relay/src/noise/noise.test.ts`: vendored vectors, replay and out-of-order rejection at the cipher-state level.
- `packages/relay/src/protected-records.test.ts`: framing round trips and every malformed-record rule.
- `packages/relay/src/secure-channel.test.ts`: both modes, mixed versions, policy rejection, retries, recorded-transcript replay, downgrade attempts.
- `packages/server/src/server/websocket/secure-relay-ingress.test.ts`: daemon admission and the flag.
- `packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts`: real daemon through the wrangler relay. Set `PASEO_RELAY_E2E_PORT` to run the same cases against an already-running relay such as the Elixir relay in Docker.
+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-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
+42
View File
@@ -8805,6 +8805,45 @@
"license": "MIT",
"optional": true
},
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.4.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -42184,6 +42223,9 @@
"name": "@getpaseo/relay",
"version": "0.13.0-beta.1",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/hashes": "^2.4.0",
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
"ws": "^8.14.2"
@@ -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;
@@ -5,6 +5,7 @@ import {
applyCommandCenterAgentControls,
chooseCommandCenterAgentControl,
openCommandCenterForAgent,
searchAgentControls,
expectCommandCenterAgentControlSelected,
expectFocusedAgentControls,
expectWorkspaceAgentConfiguration,
@@ -111,8 +112,8 @@ test.describe("Command Center agent controls", () => {
});
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("load");
await openCommandCenterForAgent(page, workspace);
await searchAgentControls(page, "load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
@@ -184,7 +185,7 @@ test.describe("Command Center agent controls", () => {
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("loa");
await searchAgentControls(page, "loa");
// Moves the highlight onto a model row, which still matches after the next keystroke.
await page.keyboard.press("ArrowDown");
await input.fill("load");
@@ -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);
}
@@ -4,13 +4,14 @@ import { withButtonShowcase } from "../support/helpers/plugin-buttons";
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
page,
}) => {
test.setTimeout(180_000);
await withButtonShowcase(page, async (buttons) => {
}, info) => {
test.setTimeout(240_000);
await withButtonShowcase(page, info, async (buttons) => {
await buttons.verifyObservationLifetime();
await buttons.openWideMenusAndPopovers();
await buttons.runAndUpdateActions();
await buttons.openCompactSheets();
await buttons.useCompactWorkspaceMenu();
await buttons.hideAndUnloadOpenButtons();
});
});
@@ -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");
@@ -25,6 +25,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
/^Sort/,
/^Title/,
"Show",
"Project",
@@ -32,6 +32,7 @@ test.describe("Sidebar project filter", () => {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
/^Sort/,
/^Title/,
"Show",
"Project",
@@ -1,9 +1,10 @@
import type { Locator } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { getServerId } from "../support/helpers/server-id";
import { seedWorkspace } from "../support/helpers/seed-client";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
async function rowTestIds(rows: Locator) {
@@ -57,7 +58,19 @@ async function quickDragFirstRowAfterSecond(
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
}
test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({
async function dragWithoutReordering(rows: Locator): Promise<void> {
const before = await rowTestIds(rows);
const source = await visibleBoundingBox(rows.nth(0));
const target = await visibleBoundingBox(rows.nth(1));
const page = rows.page();
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
await page.mouse.down();
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual(before);
}
test("projects and pinned chats reorder while ordinary workspaces stay newest first", async ({
page,
}) => {
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
@@ -87,12 +100,13 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra
);
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
await quickDragFirstRowAfterSecond(
page.locator(
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
),
pressWorkspaceRow,
const ordinaryRows = page.locator(
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
);
await expect
.poll(() => rowTestIds(ordinaryRows))
.toEqual([secondWorkspaceTestId, firstWorkspaceTestId]);
await dragWithoutReordering(ordinaryRows);
await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true);
await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true);
@@ -108,3 +122,243 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra
await secondProject.cleanup();
}
});
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: /^Sort/ }).click();
await page.getByRole("menuitem", { name, exact: true }).click();
}
async function reconnectSidebar(page: Page): Promise<void> {
await page.reload();
await waitForSidebarHydration(page);
}
async function expectNoWorkspaceSortChoice(page: Page): Promise<void> {
await page.getByLabel("Display preferences", { exact: true }).click();
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0);
}
async function promptWorkspace(seed: Awaited<ReturnType<typeof seedWorkspace>>, agentId: string) {
await seed.client.sendAgentMessage(agentId, "sidebar ordering check");
expect((await seed.client.waitForFinish(agentId, 30_000)).status).toBe("idle");
}
test("Created stays stable, Last prompt follows human submissions and survives tab archive", async ({
page,
}) => {
test.setTimeout(120_000);
const seed = await seedWorkspace({ repoPrefix: "sidebar-sort-" });
try {
const newer = await seed.client.createWorkspace({
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
title: "Newer workspace",
});
if (!newer.workspace) throw new Error(newer.error ?? "Workspace creation failed");
const agent = await seed.client.createAgent({
provider: "mock",
cwd: seed.repoPath,
workspaceId: seed.workspaceId,
modeId: "load-test",
model: "e2e-fast-stream",
title: "Ordering agent",
});
const newerAgent = await seed.client.createAgent({
provider: "mock",
cwd: seed.repoPath,
workspaceId: newer.workspace.id,
modeId: "load-test",
model: "e2e-fast-stream",
title: "Newer agent",
});
await gotoAppShell(page);
await waitForSidebarHydration(page);
const oldId = `sidebar-workspace-row-${getServerId()}:${seed.workspaceId}`;
const newId = `sidebar-workspace-row-${getServerId()}:${newer.workspace.id}`;
const rows = page.locator(`[data-testid="${oldId}"], [data-testid="${newId}"]`);
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
await promptWorkspace(seed, agent.id);
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
await selectWorkspaceSort(page, "Last prompt");
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
await promptWorkspace(seed, newerAgent.id);
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
await promptWorkspace(seed, agent.id);
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
await seed.client.archiveAgent(agent.id);
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
await reconnectSidebar(page);
await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]);
await selectWorkspaceSort(page, "Created");
await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]);
await selectSidebarStatusGrouping(page);
await expectNoWorkspaceSortChoice(page);
} finally {
await seed.cleanup();
}
});
async function seedCompactProject() {
const seed = await seedWorkspace({
repoPrefix: "sidebar-visibility-",
title: "Oldest workspace",
});
const ids = [seed.workspaceId];
for (let index = 2; index <= 12; index += 1) {
const created = await seed.client.createWorkspace({
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
title: `Workspace ${index}`,
});
if (!created.workspace) throw new Error(created.error ?? "Workspace creation failed");
ids.unshift(created.workspace.id);
}
const agent = await seed.client.createAgent({
provider: "mock",
model: "e2e-fast-stream",
modeId: "load-test",
cwd: seed.repoPath,
workspaceId: seed.workspaceId,
title: "Overflow agent",
});
return { seed, ids, agent };
}
async function expectWorkspaceOrder(rows: Locator, ids: string[]) {
await expect
.poll(() => rowTestIds(rows))
.toEqual(ids.map((id) => `sidebar-workspace-row-${getServerId()}:${id}`));
}
async function revealWorkspaces(list: Locator, action: "Show more" | "Show less") {
await list.getByLabel(action, { exact: true }).click();
}
async function selectProjectGrouping(page: Page) {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: /^Group/ }).click();
await page.getByTestId("sidebar-grouping-project").click();
}
type CompactProject = Awaited<ReturnType<typeof seedCompactProject>>;
function compactProjectRows(page: Page, seed: CompactProject["seed"]) {
const list = page.getByTestId(`sidebar-workspace-list-${seed.projectKey}`);
return {
rows: list.locator('[data-testid^="sidebar-workspace-row-"]'),
group: list.locator(".."),
project: page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seed.projectKey)}`),
};
}
async function expectActiveOverflowTransitions(page: Page, { seed, ids, agent }: CompactProject) {
const { rows } = compactProjectRows(page, seed);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
await seed.client.sendAgentMessage(agent.id, "show active overflow");
await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]);
await seed.client.waitForFinish(agent.id, 15_000);
await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]);
await seed.client.clearWorkspaceAttention(seed.workspaceId);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
}
async function expectRevealSurvivesSortingAndCollapse(
page: Page,
{ seed, ids, agent }: CompactProject,
) {
const { rows, group, project } = compactProjectRows(page, seed);
const retainedRows = await rows.elementHandles();
await revealWorkspaces(group, "Show more");
await expectWorkspaceOrder(rows, ids);
for (const [index, retained] of retainedRows.entries()) {
expect(
await rows.nth(index).evaluate((current, previous) => current === previous, retained),
).toBe(true);
}
await project.click();
await expect(rows).toHaveCount(0);
await project.click();
await expectWorkspaceOrder(rows, ids);
await seed.client.sendAgentMessage(agent.id, "expanded overflow");
await seed.client.waitForFinish(agent.id, 15_000);
await seed.client.clearWorkspaceAttention(seed.workspaceId);
await expectWorkspaceOrder(rows, ids);
await selectWorkspaceSort(page, "Last prompt");
await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 11)]);
const revealedRow = await rows.nth(8).elementHandle();
await revealWorkspaces(group, "Show less");
await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 6)]);
await selectWorkspaceSort(page, "Created");
await expectWorkspaceOrder(rows, ids.slice(0, 7));
expect(await revealedRow!.evaluate((row) => row.isConnected)).toBe(false);
await reconnectSidebar(page);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
}
async function expectStatusBucketsRetainActiveOverflow(
page: Page,
{ seed, ids, agent }: CompactProject,
) {
const { rows } = compactProjectRows(page, seed);
await selectSidebarStatusGrouping(page);
const done = page.getByTestId("sidebar-status-group-rows-done");
const doneRows = done.locator('[data-testid^="sidebar-workspace-row-"]');
await expect(doneRows).toHaveCount(7);
await revealWorkspaces(done, "Show more");
await expect(doneRows).toHaveCount(12);
await seed.client.sendAgentMessage(agent.id, "status overflow");
await seed.client.waitForFinish(agent.id, 15_000);
await expect(
page
.getByTestId("sidebar-status-group-rows-attention")
.getByText("Oldest workspace", { exact: true }),
).toBeVisible();
await expect(doneRows).toHaveCount(11);
await seed.client.clearWorkspaceAttention(seed.workspaceId);
await expect(doneRows).toHaveCount(12);
await revealWorkspaces(done, "Show less");
await expect(doneRows).toHaveCount(7);
const activeAgents: Array<{ id: string }> = [];
for (const workspaceId of ids.slice(0, 8)) {
activeAgents.push(
await seed.client.createAgent({
provider: "mock",
model: "e2e-fast-stream",
modeId: "load-test",
cwd: seed.repoPath,
workspaceId,
title: "Active overflow agent",
}),
);
}
await Promise.all(
activeAgents.map((item) => seed.client.sendAgentMessage(item.id, "active bucket")),
);
await Promise.all(activeAgents.map((item) => seed.client.waitForFinish(item.id, 15_000)));
const attention = page.getByTestId("sidebar-status-group-rows-attention");
await expect(attention.locator('[data-testid^="sidebar-workspace-row-"]')).toHaveCount(8);
await expect(attention.getByLabel("Show more", { exact: true })).toHaveCount(0);
for (const workspaceId of ids.slice(0, 8)) await seed.client.clearWorkspaceAttention(workspaceId);
await expect(doneRows).toHaveCount(7);
await selectProjectGrouping(page);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
}
test("compact groups retain active overflow and reveal the current ordered rows", async ({
page,
}) => {
test.setTimeout(120_000);
const fixture = await seedCompactProject();
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await test.step("Active overflow appears without intervening Done rows", () =>
expectActiveOverflowTransitions(page, fixture));
await test.step("Reveal follows current sort and survives collapse", () =>
expectRevealSurvivesSortingAndCollapse(page, fixture));
await test.step("Status reveals Done rows and keeps every active row", () =>
expectStatusBucketsRetainActiveOverflow(page, fixture));
} finally {
await fixture.seed.cleanup();
}
});
@@ -0,0 +1,84 @@
import type { Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { randomUUID } from "node:crypto";
import { metroTest as test, expect } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { seedWorkspace } from "../support/helpers/seed-client";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
test("published old daemon keeps workspace navigation and gates sorting", async ({ page }) => {
test.setTimeout(180_000);
const daemon = await startIsolatedHostDaemon(`srv_sort_old_${randomUUID()}`, {
publishedVersion: "0.8.0",
});
try {
const project = await seedWorkspace({ repoPrefix: "sidebar-old-host-", port: daemon.port });
try {
const second = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
title: "Second old workspace",
});
expect(second.workspace).toBeTruthy();
await openPublishedHost(page, daemon, project.workspaceId);
await openWorkspace(page, daemon.serverId, second.workspace!.id);
await expectSortingRequiresHostUpdate(page);
await selectSidebarStatusGrouping(page);
await expectStatusHasNoSorting(page);
} finally {
await project.cleanup();
}
} finally {
await daemon.close();
}
});
async function openPublishedHost(
page: Page,
daemon: { serverId: string; port: number },
workspaceId: string,
) {
const host = buildSeededHost({
serverId: daemon.serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript((seededHost) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem(
"sidebar-view",
JSON.stringify({ version: 8, state: { groupMode: "project", workspaceSort: "lastPrompt" } }),
);
}, host);
await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId));
}
async function openWorkspace(page: Page, serverId: string, workspaceId: string) {
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
await expect(row).toBeVisible();
await row.click();
await expect(page).toHaveURL(new RegExp(workspaceId));
}
async function expectSortingRequiresHostUpdate(page: Page) {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: /^Sort/ }).click();
await expect(
page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }),
).toBeDisabled();
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
}
async function expectStatusHasNoSorting(page: Page) {
await page.getByLabel("Display preferences", { exact: true }).click();
await expect(page.getByRole("menuitem", { name: "Update hosts to sort workspaces" })).toHaveCount(
0,
);
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0);
}
@@ -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);
});
});
@@ -1,6 +1,10 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { holdFileUploadResponse } from "../support/helpers/file-upload-gate";
import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf";
@@ -26,12 +30,130 @@ process.stdin.on("data", (chunk) => {
setTimeout(finish, 700);
`;
// A TUI stand-in: turns on bracketed paste, reads every quoted path in each
// bracketed paste, and echoes typed lines, so the PTY output proves what reached
// the terminal and where the file was read from.
const FILE_RECEIVER_SCRIPT = String.raw`
const fs = require("node:fs");
const crypto = require("node:crypto");
let buffer = "";
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdout.write("\x1b[?2004hFILE_RECEIVER_READY\r\n");
process.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
if (!paste) break;
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
process.stdout.write("FILE_READ:" + digest + ":" + location + "\r\n");
}
buffer = buffer.slice(0, paste.index) + buffer.slice(paste.index + paste[0].length);
}
if (buffer.includes("\x03")) {
process.stdout.write("INTERRUPTED\r\n");
buffer = buffer.replaceAll("\x03", "");
}
const enter = buffer.indexOf("\r");
if (enter >= 0 && !buffer.includes("'")) {
process.stdout.write("TYPED:" + buffer.slice(0, enter) + "\r\n");
buffer = buffer.slice(enter + 1);
}
});
`;
function pngBytes(seed: number): Buffer {
return Buffer.concat([Buffer.from("iVBORw0KGgo=", "base64"), Buffer.from([seed])]);
}
function fileRead(bytes: Buffer, location: "upload" | "local"): string {
const digest = createHash("sha256").update(bytes).digest("hex");
return `FILE_READ:${digest}:${location}`;
}
// Inactive terminal tabs retain their surfaces, and another page opening a terminal in
// the same workspace adds a tab here too. Only the selected tab's surface is visible.
function terminalSurface(page: Page) {
return page.getByTestId("terminal-surface").filter({ visible: true });
}
function terminalInput(page: Page) {
return terminalSurface(page).locator(".xterm-helper-textarea");
}
async function pasteImage(page: Page, bytes: Buffer): Promise<void> {
await terminalInput(page).evaluate(
(element, data) => {
(element as HTMLTextAreaElement).focus();
const clipboardData = new DataTransfer();
clipboardData.items.add(new File([new Uint8Array(data)], "image.png", { type: "image/png" }));
element.dispatchEvent(
new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }),
);
},
[...bytes],
);
}
interface DroppedFile {
name: string;
type: string;
bytes: Buffer;
}
async function dragTransfer(page: Page, files: DroppedFile[]) {
return await page.evaluateHandle(
(entries) => {
const dataTransfer = new DataTransfer();
for (const entry of entries) {
dataTransfer.items.add(
new File([new Uint8Array(entry.bytes)], entry.name, { type: entry.type }),
);
}
return dataTransfer;
},
files.map((file) => ({ ...file, bytes: [...file.bytes] })),
);
}
async function dragFilesOver(page: Page, files: DroppedFile[]): Promise<void> {
const dataTransfer = await dragTransfer(page, files);
await terminalInput(page).dispatchEvent("dragenter", { dataTransfer });
await terminalInput(page).dispatchEvent("dragover", { dataTransfer });
await dataTransfer.dispose();
}
async function dropFiles(page: Page, files: DroppedFile[]): Promise<void> {
const dataTransfer = await dragTransfer(page, files);
await terminalInput(page).dispatchEvent("drop", { dataTransfer });
await dataTransfer.dispose();
}
/** The shared "Drop files here" overlay of the zone around the terminal. */
function terminalDropOverlay(page: Page) {
return terminalSurface(page)
.locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]")
.getByTestId("file-drop-backdrop");
}
/**
* Makes the app treat the e2e daemon as the desktop app's own daemon, so dropped
* files resolve to paths inside `directory` instead of uploading.
*/
async function typeLine(page: Page, text: string): Promise<void> {
await terminalInput(page).pressSequentially(text);
await terminalInput(page).press("Enter");
}
test.describe("Terminal protocol queries", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" });
await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT);
await writeFile(path.join(harness.tempRepo.path, "file-receiver.cjs"), FILE_RECEIVER_SCRIPT);
});
test.afterAll(async () => {
@@ -58,4 +180,156 @@ test.describe("Terminal protocol queries", () => {
await harness.killTerminal(terminalInstance.id);
}
});
async function openFileReceiver(page: Page, name: string) {
const terminal = await harness.createTerminal({
name,
command: process.execPath,
args: ["file-receiver.cjs"],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await expectTerminalOutput(terminal.id, "FILE_RECEIVER_READY");
return terminal;
}
// Reads the PTY itself, so output from one pane cannot satisfy another's check.
async function terminalOutput(terminalId: string): Promise<string> {
const capture = await harness.client.captureTerminal(terminalId);
return capture.lines.join("").replace(/\s/g, "");
}
async function expectTerminalOutput(terminalId: string, text: string): Promise<void> {
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
const secondPage = await page.context().newPage();
const first = await openFileReceiver(page, "image-paste-a");
const second = await openFileReceiver(secondPage, "image-paste-b");
try {
await Promise.all([pasteImage(page, pngBytes(1)), pasteImage(secondPage, pngBytes(2))]);
await expectTerminalOutput(first.id, fileRead(pngBytes(1), "upload"));
await expectTerminalOutput(second.id, fileRead(pngBytes(2), "upload"));
expect(await terminalOutput(first.id)).not.toContain(fileRead(pngBytes(2), "upload"));
expect(await terminalOutput(second.id)).not.toContain(fileRead(pngBytes(1), "upload"));
} finally {
await secondPage.close();
await harness.killTerminal(first.id);
await harness.killTerminal(second.id);
}
});
test("uploads dropped files of any type when the host is another machine", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote");
const files = [
{ name: "shot.png", type: "image/png", bytes: pngBytes(3) },
{ name: "notes.txt", type: "text/plain", bytes: Buffer.from("remote notes") },
];
try {
await dragFilesOver(page, files);
await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "1");
await expect(terminalDropOverlay(page)).toHaveText("Drop files here");
await dropFiles(page, files);
await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "0");
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
} finally {
await harness.killTerminal(terminal.id);
}
});
test("pastes dropped files' own paths when the host is this machine", async ({ page }) => {
const files = [
{ name: "local-shot.png", type: "image/png", bytes: pngBytes(9) },
{ name: "local-notes.txt", type: "text/plain", bytes: Buffer.from("local notes") },
];
for (const file of files) {
await writeFile(path.join(harness.tempRepo.path, file.name), file.bytes);
}
await installLocalDesktopHost(page, harness.tempRepo.path);
const terminal = await openFileReceiver(page, "file-drop-local");
try {
await dropFiles(page, files);
await expectTerminalOutput(terminal.id, fileRead(pngBytes(9), "local"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("local notes"), "local"));
expect(await terminalOutput(terminal.id)).not.toContain(":upload");
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("lands image pastes in gesture order while typing stays live", async ({ page }) => {
const upload = await holdFileUploadResponse(page);
const terminal = await openFileReceiver(page, "image-order");
try {
await pasteImage(page, pngBytes(4));
await upload.waitUntilHeld();
await pasteImage(page, pngBytes(5));
await expect(page.getByTestId("terminal-file-uploading")).toHaveText("Uploading file…");
await typeLine(page, "describe");
await expectTerminalOutput(terminal.id, "TYPED:describe");
upload.release();
await expectTerminalOutput(terminal.id, fileRead(pngBytes(5), "upload"));
const output = await terminalOutput(terminal.id);
expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeGreaterThanOrEqual(0);
expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeLessThan(
output.indexOf(fileRead(pngBytes(5), "upload")),
);
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
await expect(page.getByTestId("app-toast-message")).toHaveText("File uploaded");
} finally {
await harness.killTerminal(terminal.id);
}
});
test("Ctrl+C abandons an image paste that is still uploading", async ({ page }) => {
const upload = await holdFileUploadResponse(page);
const terminal = await openFileReceiver(page, "image-cancel");
try {
await pasteImage(page, pngBytes(6));
await upload.waitUntilHeld();
await terminalInput(page).press("Control+c");
await expectTerminalOutput(terminal.id, "INTERRUPTED");
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
upload.release();
await pasteImage(page, pngBytes(7));
await expectTerminalOutput(terminal.id, fileRead(pngBytes(7), "upload"));
expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(6), "upload"));
} finally {
await harness.killTerminal(terminal.id);
}
});
test("a disconnect during upload reports one failure and input keeps working", async ({
page,
}) => {
const upload = await holdFileUploadResponse(page);
const terminal = await openFileReceiver(page, "image-disconnect");
try {
await pasteImage(page, pngBytes(8));
await upload.waitUntilHeld();
await upload.disconnect();
await expect(page.getByTestId("app-toast-message")).toHaveText(
"Failed to paste file: Host is not connected",
);
await typeLine(page, "after");
await expectTerminalOutput(terminal.id, "TYPED:after");
expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(8), "upload"));
} finally {
await harness.killTerminal(terminal.id);
}
});
});
@@ -11,6 +11,7 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -93,10 +94,20 @@ async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
await closeButton.first().click();
}
async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
await page.evaluate((url) => {
window.history.pushState(null, "", url);
window.dispatchEvent(new PopStateEvent("popstate"));
}, route);
await expectAppRoute(page, route.split("?")[0]);
}
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("opens a notification's workspace on a different offline host", async ({ page }) => {
test("keeps an offline notification on the agent route until the host connects", async ({
page,
}) => {
const target = {
serverId: "notification-offline-host",
workspaceId: "notification-workspace",
@@ -123,14 +134,33 @@ test.describe("Workspace navigation regression", () => {
);
}, target);
await expectAppRoute(
page,
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
{ timeout: 30_000 },
);
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
await expectAppRoute(page, buildHostAgentDetailRoute(target.serverId, target.agentId), {
timeout: 30_000,
});
await expect(page.getByTestId("agent-route-waiting-for-host")).toBeVisible();
await expect(page.getByText(/Notification Host/).first()).toBeVisible();
});
test("cold agent targets hydrate their workspace without opening the compact sidebar", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "notification-cold-compact-" });
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Cold notification agent",
});
await page.setViewportSize({ width: 390, height: 844 });
await gotoAppShell(page);
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id));
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId), {
timeout: 30_000,
});
await expectComposerVisible(page);
} finally {
await workspace.cleanup();
}
});
test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({
@@ -321,7 +351,7 @@ test.describe("Workspace navigation regression", () => {
}
});
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
test("a stale notification workspace preserves retained workspace selection and tabs", async ({
page,
}) => {
const serverId = getServerId();
@@ -435,7 +465,7 @@ test.describe("Workspace navigation regression", () => {
}),
);
},
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
{ agentId: secondAgent.id, serverId, workspaceId: firstWorkspace.workspaceId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
@@ -463,6 +493,22 @@ test.describe("Workspace navigation regression", () => {
await expect(secondDeckEntry).toBeHidden();
await expectWorkspaceDeckEntryCount(page, 2);
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: firstWorkspace.workspaceId,
agentId: secondAgent.id,
});
await openWorkspaceTabUrl(
page,
buildHostWorkspaceOpenRoute(serverId, firstWorkspace.workspaceId, `agent:${firstAgent.id}`),
);
await expectWorkspaceTabVisible(page, firstAgent.id);
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
@@ -3,6 +3,7 @@ import { test, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { revealSidebarProjectWorkspaces } from "../support/helpers/sidebar";
import {
expectSidebarWorkspaceSelected,
switchWorkspaceViaSidebar,
@@ -46,6 +47,7 @@ test("previous and next traverse every visible workspace while digit shortcuts s
}
await page.setViewportSize({ width: 1440, height: 1100 });
await gotoAppShell(page);
await revealSidebarProjectWorkspaces(page, project.projectKey);
const order = await readVisibleWorkspaceOrder(page);
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
@@ -68,7 +70,25 @@ test("previous and next traverse every visible workspace while digit shortcuts s
workspaceId: order[8],
});
});
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
await expectHiddenWorkspaceShortcuts(page, order);
});
} finally {
await project.cleanup();
}
});
async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) {
await page.getByLabel("Show less", { exact: true }).click();
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
await expect(rows).toHaveCount(7);
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
await page.keyboard.press("Alt+9");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
0,
);
await page.keyboard.press("Alt+]");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
await expect(rows).toHaveCount(7);
}
@@ -2,7 +2,8 @@ context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms).
# unselected PINNED workspace row. Ordinary workspace rows are not draggable.
# Hold past drag arming (180ms), then release.
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
@@ -35,7 +35,8 @@ export const test = base.extend<{
import { readFile, rm } from "node:fs/promises";
export default function contribute(server) {
return server.before("agent.session_open", async ({ request }) => {
if (request.reason !== "create") return request;
// Metadata helpers have no workspace and must not consume the one-shot failure.
if (request.reason !== "create" || !request.workspaceId) return request;
const gate = ${JSON.stringify(gate)};
let command = await readFile(gate, "utf8").catch(() => "release");
while (command === "hold") {
@@ -18,10 +18,29 @@ 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");
}
/**
* Types a query that matches both the agent's modes and its models, and waits until rows from both
* groups are listed. The agent's controls register after the panel opens; a key pressed before
* then lands on whatever else matched, such as file search.
*/
export async function searchAgentControls(page: Page, query: string): Promise<void> {
const panel = page.getByTestId("command-center-panel");
await panel.getByTestId("command-center-input").fill(query);
await expect(panel.getByRole("button", { name: /^Mode › / }).first()).toBeVisible({
timeout: 30_000,
});
await expect(panel.getByRole("button", { name: /^Model › / }).first()).toBeVisible({
timeout: 30_000,
});
}
export async function chooseCommandCenterAgentControl(input: {
page: Page;
query: string;
@@ -17,6 +17,7 @@ import {
workspaceDeckEntryLocator,
} from "./workspace-ui";
import { getServerId } from "./server-id";
import { revealSidebarProjectWorkspaces } from "./sidebar";
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
@@ -282,6 +283,7 @@ export async function createCreationScenario(page: Page) {
});
}
await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0);
await revealSidebarProjectWorkspaces(page, project.projectKey);
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId });
await page
.getByTestId(draftTestId!)
@@ -0,0 +1,44 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
function isFileUploadResponse(message: WebSocketMessage): boolean {
if (typeof message !== "string") return false;
try {
const envelope = JSON.parse(message) as { type?: unknown; message?: { type?: unknown } };
return (envelope.message ?? envelope).type === "file.upload.response";
} catch {
return false;
}
}
/**
* Holds the daemon's first `file.upload.response`, so the upload is stored on
* the host but the app still sees it in flight.
*/
export async function holdFileUploadResponse(page: Page) {
let held: { browser: WebSocketRoute; message: WebSocketMessage } | null = null;
let resolveHeld: (() => void) | null = null;
const heldResponse = new Promise<void>((resolve) => {
resolveHeld = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (held || !isFileUploadResponse(message)) {
browser.send(message);
return;
}
held = { browser, message };
resolveHeld?.();
});
});
return {
waitUntilHeld: () => heldResponse,
release: () => held?.browser.send(held.message),
/** Drops the app's connection while the upload is in flight. */
disconnect: () => held?.browser.close({ code: 1001, reason: "Upload gate disconnect" }),
};
}
@@ -0,0 +1,38 @@
import type { Page } from "@playwright/test";
// Poses as the desktop app running the e2e daemon as its own managed daemon, so
// `useIsLocalDaemon` reports the host as this machine. `directory` is where the
// desktop bridge claims dropped files live on disk.
export async function installLocalDesktopHost(page: Page, directory: string): Promise<void> {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set");
}
await page.addInitScript(
(localHost: { serverId: string; directory: string }) => {
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "linux",
invoke: async (command: string) => {
if (command === "desktop_daemon_status") {
return {
serverId: localHost.serverId,
status: "running",
listen: null,
hostname: null,
pid: null,
home: "",
version: null,
desktopManaged: true,
error: null,
};
}
return null;
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
webUtils: { getPathForFile: (file: File) => `${localHost.directory}/${file.name}` },
};
},
{ serverId, directory },
);
}
@@ -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,11 +1,17 @@
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator } from "@playwright/test";
import type { Locator, TestInfo } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
import {
closeCompactWorkspaceMenu,
compactWorkspaceMenu,
goBackInMenu,
openCompactWorkspaceMenu,
} from "./compact-workspace-scripts";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { pluginRequirements } from "./plugin-fixture";
@@ -60,7 +66,7 @@ export default function contribute(client) {
let finishReview = () => {};
let attempts = 0;
const action = { kind: "action", async onPress() {
if (++attempts === 1) throw new Error("Deployment failed. Try again.");
if (++attempts % 2 === 1) throw new Error("Deployment failed. Try again.");
await new Promise((resolve) => { finish = resolve; });
deploy.update({ label: "Deployed" });
} };
@@ -165,6 +171,13 @@ async function command(page: Page, title: string) {
await expect(panel).not.toBeVisible();
}
/** The command center opens from the sidebar, which compact layouts keep closed. */
async function commandFromCompact(page: Page, title: string) {
await page.setViewportSize(WIDE);
await command(page, title);
await page.setViewportSize(COMPACT);
}
async function expectSurfaceVisible(
page: Page,
subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
@@ -176,6 +189,61 @@ async function expectDetails(page: Page) {
await expect(page.getByText("All systems operational", { exact: true })).toBeInViewport();
}
function workspaceMenuRow(page: Page, title: string): Locator {
return compactWorkspaceMenu(page).getByRole("menuitem", { name: title, exact: true });
}
async function expectWorkspaceMenuRows(page: Page, titles: readonly string[]) {
const rows = compactWorkspaceMenu(page).getByRole("menuitem");
for (const [index, title] of titles.entries()) {
await expect(rows.nth(index)).toHaveAccessibleName(title);
}
}
async function chooseInWorkspaceMenu(page: Page, title: string) {
await workspaceMenuRow(page, title).click();
}
async function expectWorkspaceMenuClosed(page: Page) {
await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
}
async function expectCompactDetails(page: Page) {
const menu = compactWorkspaceMenu(page);
await expect(menu.getByText("All systems operational", { exact: true })).toBeInViewport();
await expect(menu.getByText("Presentation: compact", { exact: true })).toBeVisible();
}
/** Resolves once `subject` renders the same pixels twice in a row: entering animations are done. */
async function expectStill(subject: Locator) {
let previous: Buffer | null = null;
await expect
.poll(
async () => {
const current = await subject.screenshot();
const still = previous !== null && current.equals(previous);
previous = current;
return still;
},
{ intervals: [150] },
)
.toBe(true);
}
/** Attaches the screen once per colour scheme, for review alongside the run. */
async function captureSchemes(page: Page, info: TestInfo, name: string, subject: Locator) {
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await expectStill(subject);
const file = info.outputPath(`${name}-${colorScheme}.png`);
await info.attach(`${name}-${colorScheme}`, {
body: await page.screenshot({ path: file }),
contentType: "image/png",
});
}
await page.emulateMedia({ colorScheme: "light" });
}
async function expectHeaderTextTruncated(page: Page) {
const title = page.getByTestId("workspace-header-title");
const subtitle = page.getByTestId("workspace-header-subtitle");
@@ -204,10 +272,12 @@ async function expectHeaderTextTruncated(page: Page) {
export async function withButtonShowcase(
page: Page,
info: TestInfo,
run: (buttons: {
openWideMenusAndPopovers(): Promise<void>;
runAndUpdateActions(): Promise<void>;
openCompactSheets(): Promise<void>;
useCompactWorkspaceMenu(): Promise<void>;
hideAndUnloadOpenButtons(): Promise<void>;
verifyObservationLifetime(): Promise<void>;
}) => Promise<void>,
@@ -284,6 +354,12 @@ export async function withButtonShowcase(
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await expectSurfaceVisible(page);
await captureSchemes(
page,
info,
"desktop-wide-header",
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
);
await press(page, "Header checks");
await expect(
page.getByRole("menuitem", { name: "Run checks", exact: true }),
@@ -303,6 +379,12 @@ export async function withButtonShowcase(
await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await captureSchemes(
page,
info,
"desktop-overflow",
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Production locked", exact: true }),
@@ -372,7 +454,19 @@ export async function withButtonShowcase(
await expectSurfaceVisible(page);
await command(page, "Enable example buttons");
await command(page, "Use icon-only header");
await expect(
page.getByRole("button", { name: "Deploy application", exact: true }),
).toHaveText("");
await expectSurfaceVisible(page);
await expect(
page.getByText("Deployment failed. Try again.", { exact: true }),
).toHaveCount(0);
await captureSchemes(
page,
info,
"desktop-icon-only-header",
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
);
await command(page, "Hide review button");
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
0,
@@ -383,7 +477,7 @@ export async function withButtonShowcase(
);
}),
openCompactSheets: () =>
test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
@@ -422,6 +516,98 @@ export async function withButtonShowcase(
);
await choose(page, "Run checks");
}),
useCompactWorkspaceMenu: () =>
test.step("compact workspace menu carries header buttons with their actions, menus, and content", async () => {
await openCompactWorkspaceMenu(page);
await expectWorkspaceMenuRows(page, [
"Deploy application",
"Header checks",
"Header status",
"Open deployment logs",
"Production locked",
"Copy workspace path",
]);
await expect(workspaceMenuRow(page, "Production locked")).toBeDisabled();
// Deploy lost its label to "Use icon-only header" and logs never had one; rows read titles.
await expect(workspaceMenuRow(page, "Deploy application")).toHaveText(
"Deploy application",
);
await expect(workspaceMenuRow(page, "Open deployment logs")).toHaveText(
"Open deployment logs",
);
await captureSchemes(page, info, "compact-root-icon-only", compactWorkspaceMenu(page));
await showcase.setTitle("Compact checks");
await expect(workspaceMenuRow(page, "Deploy application")).toHaveCount(0);
await showcase.setTitle("Button showcase");
await expect(workspaceMenuRow(page, "Deploy application")).toBeVisible();
await chooseInWorkspaceMenu(page, "Deploy application");
await expectWorkspaceMenuClosed(page);
await expect(
page.getByText("Deployment failed. Try again.", { exact: true }),
).toBeVisible();
await openCompactWorkspaceMenu(page);
await chooseInWorkspaceMenu(page, "Deploy application");
await expectWorkspaceMenuClosed(page);
await openCompactWorkspaceMenu(page);
await expect(workspaceMenuRow(page, "Deploy application")).toBeDisabled();
await captureSchemes(page, info, "compact-row-states", compactWorkspaceMenu(page));
await closeCompactWorkspaceMenu(page);
await commandFromCompact(page, "Finish deployment");
await openCompactWorkspaceMenu(page);
await expect(workspaceMenuRow(page, "Deploy application")).toBeEnabled();
await captureSchemes(page, info, "compact-root", compactWorkspaceMenu(page));
await chooseInWorkspaceMenu(page, "Header checks");
await expectWorkspaceMenuRows(page, ["Run checks", "Environment", "Deployment details"]);
await captureSchemes(page, info, "compact-header-checks", compactWorkspaceMenu(page));
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Header status")).toBeVisible();
await chooseInWorkspaceMenu(page, "Header checks");
await chooseInWorkspaceMenu(page, "Environment");
await expectWorkspaceMenuRows(page, ["Staging", "Production"]);
await expect(workspaceMenuRow(page, "Production")).toBeDisabled();
await captureSchemes(page, info, "compact-environment", compactWorkspaceMenu(page));
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Run checks")).toBeVisible();
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Header checks")).toBeVisible();
await chooseInWorkspaceMenu(page, "Header checks");
await chooseInWorkspaceMenu(page, "Deployment details");
await expectCompactDetails(page);
await captureSchemes(
page,
info,
"compact-deployment-details",
compactWorkspaceMenu(page),
);
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Run checks")).toBeVisible();
await chooseInWorkspaceMenu(page, "Deployment details");
await expectCompactDetails(page);
await press(page, "Close deployment details");
await expectWorkspaceMenuClosed(page);
await openCompactWorkspaceMenu(page);
await chooseInWorkspaceMenu(page, "Header status");
await expectCompactDetails(page);
await captureSchemes(page, info, "compact-header-status", compactWorkspaceMenu(page));
await press(page, "Fail button content");
await expect(
compactWorkspaceMenu(page).getByText("Plugin failed: Button content failed", {
exact: true,
}),
).toBeVisible();
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Header status")).toBeVisible();
await chooseInWorkspaceMenu(page, "Open deployment logs");
await expectCompactDetails(page);
await captureSchemes(page, info, "compact-logs-popover", compactWorkspaceMenu(page));
await press(page, "Close deployment details");
await expectWorkspaceMenuClosed(page);
}),
hideAndUnloadOpenButtons: () =>
test.step("hiding an open button and unloading a plugin remove its surfaces", async () => {
await page.setViewportSize(WIDE);
@@ -454,6 +640,12 @@ export async function withButtonShowcase(
0,
);
await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0);
await page.setViewportSize(COMPACT);
await openCompactWorkspaceMenu(page);
await expect(workspaceMenuRow(page, "Header checks")).toHaveCount(0);
await expect(workspaceMenuRow(page, "Header status")).toHaveCount(0);
await closeCompactWorkspaceMenu(page);
await page.setViewportSize(WIDE);
await showcase.setTitle("Healthy app after plugin unload");
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Healthy app after plugin unload",
@@ -1,5 +1,6 @@
import { expect, type Page } from "@playwright/test";
import { getServerId } from "./server-id";
import { projectEquivalenceViewKey } from "./project-view-key";
interface ContextMenuAnchor {
x: number;
@@ -295,3 +296,12 @@ export async function expectMobileExplorerHidden(page: Page): Promise<void> {
timeout: 5_000,
});
}
export async function revealSidebarProjectWorkspaces(
page: Page,
projectKey: string,
): Promise<void> {
await page
.getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(projectKey)}`)
.click();
}
@@ -68,8 +68,8 @@ export async function navigateToTerminal(
const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId);
await page.goto(workspaceRoute);
// The workspace layout consumes `?open=...`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
// The route seeds the legacy URL target into the pending-open model, then
// removes the query. Tab readiness below waits for that intent to be consumed.
// On CI, Expo Router's rootNavigationState may take time to initialize,
// so we allow a generous timeout here.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
@@ -86,7 +86,8 @@ export async function navigateToTerminal(
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
await terminalTab.click();
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
// Inactive terminal tabs retain their surfaces; interact with the selected tab.
const terminalSurface = page.getByTestId("terminal-surface").filter({ visible: true });
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import {
@@ -61,3 +61,26 @@ export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: stri
JSON.stringify(stored),
);
}
export async function expectAgentAbsentFromWorkspaceLayout(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (!raw) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema })
.parse(JSON.parse(raw));
const key = `${input.serverId}:${input.workspaceId}`;
const layout = stored.state.layoutByWorkspace[key];
const pins = stored.state.pinnedAgentIdsByWorkspace?.[key] ?? [];
// The persisted tree is validated above; inspect all panes including hidden ones.
return {
containsAgent: JSON.stringify(layout).includes(input.agentId),
pinned: pins.includes(input.agentId),
};
})
.toEqual({ containsAgent: false, pinned: false });
}
+1 -2
View File
@@ -7,7 +7,7 @@
"start": "npm run start:expo",
"start:expo": "cross-env APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps",
"android": "npm run dev:android",
"android:development": "npm run dev:android",
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
@@ -35,7 +35,6 @@
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs",
"dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
@@ -1,89 +0,0 @@
import esbuild from "esbuild";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "..");
const repoRoot = path.resolve(appRoot, "../..");
const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts");
const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts");
async function resolveTsPath(basePath) {
const candidates = [
basePath,
`${basePath}.ts`,
`${basePath}.tsx`,
`${basePath}.js`,
`${basePath}.jsx`,
path.join(basePath, "index.ts"),
path.join(basePath, "index.tsx"),
path.join(basePath, "index.js"),
path.join(basePath, "index.jsx"),
];
for (const candidate of candidates) {
try {
const stat = await fs.stat(candidate);
if (stat.isFile()) return candidate;
} catch {
// try next candidate
}
}
return basePath;
}
const aliasPlugin = {
name: "paseo-alias",
setup(build) {
build.onResolve({ filter: /^@\// }, async (args) => ({
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
}));
build.onResolve({ filter: /^@server\// }, async (args) => ({
path: await resolveTsPath(
path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)),
),
}));
},
};
const result = await esbuild.build({
entryPoints: [entry],
bundle: true,
write: false,
format: "iife",
platform: "browser",
target: ["ios15", "chrome100"],
loader: {
".css": "text",
},
plugins: [aliasPlugin],
logLevel: "info",
});
const js = result.outputFiles[0]?.text;
if (!js) {
throw new Error("terminal webview bundle produced no JavaScript");
}
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
</head>
<body>
<script>${js}</script>
</body>
</html>`;
const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
// Do not edit by hand.
export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
`;
await fs.writeFile(output, contents);
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
+12 -124
View File
@@ -7,7 +7,6 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { LucideProvider } from "lucide-react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
import {
createContext,
@@ -16,7 +15,6 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
@@ -51,11 +49,7 @@ import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-short
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
import {
getIsElectronRuntime,
HEADER_INNER_HEIGHT,
useIsCompactFormFactor,
} from "@/constants/layout";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
@@ -97,7 +91,6 @@ import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
import {
@@ -132,14 +125,9 @@ import {
parseHostWorkspaceRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { PluginCatalogSync } from "@/plugins";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
import { NotificationOpenRouter } from "@/notification-open/react";
import { ensureOsNotificationPermission } from "@/utils/os-notifications";
polyfillCrypto();
@@ -159,114 +147,6 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
startupBlocker: { kind: "none" },
});
function PushNotificationRouter() {
const router = useRouter();
const lastHandledIdRef = useRef<string | null>(null);
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const workspaceId = target.workspaceId;
const agentId = target.agentId;
if (serverId && workspaceId && agentId) {
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
return;
}
router.navigate(buildNotificationRoute(data));
});
useEffect(() => {
if (isWeb) {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsElectronRuntime()) {
void ensureOsNotificationPermission();
const unlistenResult = getDesktopHost()?.events?.on?.(
"notification-click",
(payload: unknown) => {
const data =
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
typeof (payload as { data?: unknown }).data === "object" &&
(payload as { data?: unknown }).data !== null
? (payload as { data: Record<string, unknown> }).data
: undefined;
openNotification(data);
},
);
void Promise.resolve(unlistenResult).then((unlisten) => {
if (typeof unlisten !== "function") {
return;
}
if (cancelled) {
unlisten();
return;
}
removeDesktopNotificationListener = unlisten;
return;
});
}
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
event.preventDefault();
openNotification(customEvent.detail?.data);
};
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
};
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
// When the app is open, don't show OS banners.
shouldShowAlert: false,
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
const openFromResponse = (response: Notifications.NotificationResponse) => {
const identifier = response.notification.request.identifier;
if (lastHandledIdRef.current === identifier) {
return;
}
lastHandledIdRef.current = identifier;
const data = response.notification.request.content.data as
| Record<string, unknown>
| undefined;
openNotification(data);
};
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
openFromResponse(response);
}
return;
});
return () => {
subscription.remove();
};
}, [openNotification]);
return null;
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const client = useHostRuntimeClient(daemon.serverId);
@@ -945,6 +825,9 @@ function WorkspaceRouteNavigationBridge() {
}
function AppShell() {
useEffect(() => {
if (getDesktopHost()) void ensureOsNotificationPermission();
}, []);
return (
<MobilePanelsProvider>
<HorizontalScrollProvider>
@@ -959,10 +842,15 @@ function AppShell() {
);
}
function NotificationOpenBridge() {
const { startupBlocker } = useHostRuntimeBootstrapState();
return <NotificationOpenRouter startupBlocked={startupBlocker.kind !== "none"} />;
}
function RuntimeProviders({ children }: { children: ReactNode }) {
return (
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<NotificationOpenBridge />
<SidebarCalloutProvider>
<ProvidersWrapper>{children}</ProvidersWrapper>
</SidebarCalloutProvider>
@@ -4,6 +4,8 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
@@ -83,7 +85,16 @@ function HostAgentReadyRouteContent() {
lookupQuery.isSuccess,
shouldLookupAgent,
]);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const hasHydratedLayout = useWorkspaceLayoutStoreHydrated();
useEffect(() => {
if (!serverId) return;
// A compact app can open this route before its sidebar has demanded the directory.
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
const resolution = resolveAgentRoute({
hasHydratedWorkspaces,
hasHydratedLayout,
serverId,
agentId,
cachedWorkspaceId: agentWorkspaceId,
@@ -106,7 +117,7 @@ function HostAgentReadyRouteContent() {
handledNavigationRef.current = navigationKey;
if (resolution.kind === "resolved") {
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId, pin: true });
return;
}
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import { useNavigation } from "expo-router/react-navigation";
import { StyleSheet, View } from "react-native";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { RetainedPanel } from "@/components/retained-panel";
import {
@@ -11,7 +11,7 @@ import {
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
areRetainedWorkspaceSelectionListsEqual,
areWorkspaceSelectionsEqual,
@@ -33,7 +33,7 @@ import {
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
} from "@/utils/host-route-browser";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab";
import { isNative, isWeb } from "@/constants/platform";
import { RenderProfile } from "@/utils/render-profiler";
@@ -64,6 +64,8 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
return { kind: "draft", draftId: openIntent.draftId };
}
// COMPAT(workspace-open-url): added in v0.12.0, remove after 2027-01-22.
// Bookmarks and older clients still send ?open=; internal navigation uses pending model state.
function stripOpenSearchParamFromBrowserUrl() {
if (!isWeb || typeof window === "undefined") {
return;
@@ -81,7 +83,9 @@ function clearConsumedOpenIntent(input: {
}) {
input.navigation.setParams({ open: undefined });
if (isWeb) {
stripOpenSearchParamFromBrowserUrl();
// Expo commits the incoming browser URL after setParams. Strip the consumed legacy input
// after that commit so it cannot be restored into the address bar and replayed on reload.
window.setTimeout(stripOpenSearchParamFromBrowserUrl, 0);
}
}
@@ -95,15 +99,9 @@ export default function HostWorkspaceIndexRoute() {
function HostWorkspaceRouteContent() {
const navigation = useNavigation();
const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const globalParams = useGlobalSearchParams<{
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
@@ -111,14 +109,7 @@ function HostWorkspaceRouteContent() {
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
const isAgentOpenIntent = openIntent?.kind === "agent";
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
const openValue = getParamValue(params.open);
useEffect(() => {
if (!serverId || !workspaceId) {
return;
@@ -126,74 +117,31 @@ function HostWorkspaceRouteContent() {
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
}, [serverId, workspaceId]);
useEffect(() => {
if (!openValue) {
return;
}
if (!rootNavigationState?.key) {
return;
}
if (!hasHydratedWorkspaceLayoutStore) {
return;
}
if (isOpenIntentWaitingForWorkspace) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
setIntentConsumed(true);
return;
}
consumedIntentRef.current = consumptionKey;
useLayoutEffect(() => {
if (!openValue || !serverId || !workspaceId) return;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
});
prepareWorkspaceTab(
{
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
},
{ openTab: useWorkspaceLayoutStore.getState().deferOpenTab },
);
}
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
}, [navigation, openValue, serverId, workspaceId]);
setIntentConsumed(true);
}, [
hasHydratedWorkspaceLayoutStore,
isOpenIntentWaitingForWorkspace,
navigation,
openIntent,
openValue,
rootNavigationState?.key,
serverId,
workspaceId,
]);
if (
openValue &&
!isOpenIntentWaitingForWorkspace &&
(!intentConsumed || !hasHydratedWorkspaceLayoutStore)
) {
return null;
}
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
return <WorkspaceDeck />;
}
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
function WorkspaceDeck() {
const activeSelection = useActiveWorkspaceSelection();
const [retainedSelections, setRetainedSelections] = useState<RetainedWorkspaceSelection[]>(() =>
activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [],
@@ -264,7 +212,6 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
key={getWorkspaceSelectionKey(selection)}
selection={selection}
active={active}
recoveryRequested={recoveryRequested}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -277,12 +224,10 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
function WorkspaceDeckEntry({
selection,
active,
recoveryRequested,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
active: boolean;
recoveryRequested: boolean;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
@@ -312,7 +257,6 @@ function WorkspaceDeckEntry({
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={active}
recoveryRequested={active && recoveryRequested}
/>
</RetainedPanel>
);
@@ -0,0 +1,2 @@
/** Largest file the app sends through `client.uploadFile`. */
export const FILE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
@@ -34,7 +34,7 @@ export function FileDropBackdrop() {
// non-animated children (applying themed Unistyles styles to an Animated.View crashes
// on theme change — see docs/unistyles.md).
return (
<Animated.View style={overlayStyle} pointerEvents="none">
<Animated.View style={overlayStyle} pointerEvents="none" testID="file-drop-backdrop">
<View style={styles.backdrop} />
<View style={styles.content}>
<ThemedUpload size={32} uniProps={primaryIconColorMapping} />
@@ -13,11 +13,11 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
/**
* What a consumer (e.g. a composer) registers to receive files dropped onto the
* surrounding FileDropZone. Raster images arrive already persisted via `onFiles`;
* everything else arrives raw via `onGenericFiles`.
* surrounding FileDropZone. Every file arrives raw via `onGenericFiles`; a sink that
* also provides `onFiles` gets its raster images persisted as attachments.
*/
export interface FileDropSink {
onFiles: (images: ImageAttachment[]) => void;
onFiles?: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
}
@@ -148,8 +148,9 @@ export function useDropListeners({
sink.onGenericFiles(items);
}
const imagePaths = payload.paths.filter(isRasterImagePath);
if (imagePaths.length === 0) {
const onFiles = sink.onFiles;
const imagePaths = onFiles ? payload.paths.filter(isRasterImagePath) : [];
if (!onFiles || imagePaths.length === 0) {
return;
}
@@ -162,7 +163,7 @@ export function useDropListeners({
// composer the user dropped on (matches the web path below). No post-persist busy
// re-check: a mixed drop's own generic upload flips the busy flag, and re-checking
// would discard the image from the same drop.
sink.onFiles(attachments);
onFiles(attachments);
return;
})
.catch((error) => {
@@ -262,16 +263,17 @@ export function useDropListeners({
sink.onGenericFiles(genericItems);
}
const imageFiles = files.filter(isRasterImageFile);
const onFiles = sink.onFiles;
const imageFiles = onFiles ? files.filter(isRasterImageFile) : [];
if (imageFiles.length === 0) return;
if (!onFiles || imageFiles.length === 0) return;
try {
const attachments = await Promise.all(imageFiles.map(fileToImageAttachment));
// No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag,
// and re-checking would discard the image from the same drop. The guard at drop start
// already rejects drops that begin while busy.
sink.onFiles(attachments);
onFiles(attachments);
} catch (error) {
console.error("[useDropListeners] Failed to process dropped files:", error);
}
@@ -1,7 +1,8 @@
/**
* @vitest-environment jsdom
*/
import { act } from "@testing-library/react";
import { act, renderHook } from "@testing-library/react";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -83,6 +84,8 @@ function workspace(input: {
scripts?: WorkspaceDescriptor["scripts"];
}): WorkspaceDescriptor {
return {
createdAt: null,
lastUserMessageAt: null,
id: input.id,
projectId: input.projectId,
projectDisplayName: input.projectDisplayName,
@@ -171,10 +174,6 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
useSidebarOrderStore.setState({
projectOrder: ["project-a", "project-b"],
workspaceOrderByProject: {
["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`],
["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`],
},
});
});
}
@@ -348,7 +347,6 @@ describe("sidebar workspace render isolation", () => {
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
projectOrder: [],
workspaceOrderByProject: {},
});
});
});
@@ -439,3 +437,190 @@ describe("sidebar workspace render isolation", () => {
});
});
});
describe("sidebar group visibility", () => {
const entries = (statuses: WorkspaceDescriptor["status"][]) =>
statuses.map((status, index) =>
createSidebarWorkspaceEntry({
serverId: SERVER_ID,
workspace: workspace({
id: `row-${index + 1}`,
projectId: "project-a",
projectDisplayName: "Project A",
name: `Row ${index + 1}`,
status,
}),
}),
);
const policy = {
kind: "workspaces" as const,
getStatusBucket: (entry: ReturnType<typeof createSidebarWorkspaceEntry>) => entry.statusBucket,
};
const names = (items: readonly ReturnType<typeof createSidebarWorkspaceEntry>[]) =>
items.map((item) => item.workspaceId);
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
const items = entries(Array(count).fill("done"));
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
expect(result.current.canToggle).toBe(count > 7);
});
it.each(["needs_input", "failed", "attention", "running"] as const)(
"keeps row twelve visible for %s without filling gaps",
(status) => {
const items = entries([...Array(11).fill("done"), status]);
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
expect(names(result.current.visibleItems)).toEqual([
"row-1",
"row-2",
"row-3",
"row-4",
"row-5",
"row-6",
"row-7",
"row-12",
]);
expect(result.current.visibleItems[7]).toBe(items[11]);
expect(result.current.canToggle).toBe(true);
},
);
it("shows eight active rows once with no redundant toggle", () => {
const items = entries([
"running",
"needs_input",
"attention",
"failed",
"running",
"attention",
"needs_input",
"failed",
]);
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
expect(result.current.visibleItems).toEqual(items);
expect(result.current.canToggle).toBe(false);
});
it("recomputes current ordered membership and retains reveal through status, order and size changes", () => {
const items = entries([
...Array(7).fill("done"),
"running",
"done",
"failed",
"done",
"attention",
]);
const { result, rerender, unmount } = renderHook(
({ rows }) => useLimitedSidebarGroup(rows, policy),
{ initialProps: { rows: items } },
);
expect(names(result.current.visibleItems)).toEqual([
"row-1",
"row-2",
"row-3",
"row-4",
"row-5",
"row-6",
"row-7",
"row-8",
"row-10",
"row-12",
]);
act(() => result.current.toggleExpanded());
expect(result.current.visibleItems).toEqual(items);
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) });
expect(result.current.canToggle).toBe(false);
expect(result.current.expanded).toBe(true);
rerender({ rows: items.slice(0, 3) });
expect(result.current.canToggle).toBe(false);
rerender({ rows: items.toReversed() });
expect(result.current.visibleItems).toEqual(items.toReversed());
act(() => result.current.toggleExpanded());
expect(names(result.current.visibleItems)).toEqual([
"row-12",
"row-11",
"row-10",
"row-9",
"row-8",
"row-7",
"row-6",
]);
const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" };
rerender({ rows: [newest, ...items] });
expect(result.current.visibleItems[0]).toBe(newest);
expect(result.current.visibleItems).toHaveLength(10);
expect(result.current.expanded).toBe(false);
act(() => result.current.toggleExpanded());
unmount();
const remounted = renderHook(() => useLimitedSidebarGroup(items, policy));
expect(remounted.result.current.expanded).toBe(false);
expect(remounted.result.current.visibleItems).toHaveLength(10);
});
it("follows effective pending creation, recovery and agent activity without changing Created order", () => {
const items = entries(Array(12).fill("done"));
const descriptor = workspace({
id: "row-12",
projectId: "project-a",
projectDisplayName: "Project A",
name: "Row 12",
});
const pending = {
draftId: "draft",
serverId: SERVER_ID,
workspaceId: descriptor.id,
agentId: null,
clientMessageId: "message",
text: "Create",
timestamp: 1234,
lifecycle: "active" as const,
};
const { result, rerender } = renderHook(
({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy),
{ initialProps: { last: items[11] } },
);
expect(result.current.visibleItems).toHaveLength(7);
const creating = createSidebarWorkspaceEntry({
serverId: SERVER_ID,
workspace: descriptor,
pendingCreateAttempts: { draft: pending },
});
expect(creating.statusBucket).toBe("running");
rerender({ last: creating });
expect(names(result.current.visibleItems)).toEqual([...names(items.slice(0, 7)), "row-12"]);
const abandoned = createSidebarWorkspaceEntry({
serverId: SERVER_ID,
workspace: descriptor,
pendingCreateAttempts: { draft: { ...pending, lifecycle: "abandoned" } },
});
rerender({ last: abandoned });
expect(result.current.visibleItems).toHaveLength(7);
rerender({ last: creating });
for (const status of ["running", "attention", "needs_input", "failed", "done"] as const) {
const last = createSidebarWorkspaceEntry({
serverId: SERVER_ID,
workspace: descriptor,
workspaceAgentActivity: new Map([
[descriptor.id, { agentId: "agent", status, enteredAt: null }],
]),
});
expect(last.statusBucket).toBe(status);
rerender({ last });
expect(names(result.current.visibleItems)).toEqual(
status === "done" ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-12"],
);
}
});
it("preserves the twenty-row pinned policy regardless of status", () => {
const items = entries(Array(21).fill("running"));
const { result } = renderHook(() => useLimitedSidebarGroup(items));
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
expect(result.current.canToggle).toBe(true);
act(() => result.current.toggleExpanded());
expect(result.current.visibleItems).toEqual(items);
act(() => result.current.toggleExpanded());
expect(result.current.visibleItems).toHaveLength(20);
});
});
@@ -272,7 +272,7 @@ interface WorkspaceRowInnerProps {
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
drag: () => void;
drag?: () => void;
isDragging: boolean;
isArchiving: boolean;
isCreating?: boolean;
@@ -381,8 +381,6 @@ function getProjectWorkspaceRowStyle({
];
}
function noop() {}
const prBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
@@ -1236,7 +1234,7 @@ function WorkspaceRowWithMenu({
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
drag: () => void;
drag?: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean;
@@ -1436,7 +1434,7 @@ function WorkspaceRowItem({
enabled: selectionEnabled,
})}
onPress={handlePress}
drag={drag ?? noop}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
@@ -1465,7 +1463,7 @@ function areWorkspaceRowItemPropsEqual(
previous.hostBadge === next.hostBadge &&
previous.leadingProjectName === next.leadingProjectName &&
previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri &&
previous.shortcutNumber === next.shortcutNumber &&
(!next.showShortcutBadge || previous.shortcutNumber === next.shortcutNumber) &&
previous.showShortcutBadge === next.showShortcutBadge &&
previous.canCopyBranchName === next.canCopyBranchName &&
previous.canPin === next.canPin &&
@@ -1507,7 +1505,7 @@ function WorkspaceRow({
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
drag: () => void;
drag?: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean;
@@ -1552,16 +1550,12 @@ function ProjectBlock({
selectionEnabled,
showShortcutBadges,
shortcutIndexByWorkspaceKey,
parentGestureRef,
onToggleCollapsed,
onWorkspacePress,
onWorkspaceReorder,
onWorktreeCreated,
drag,
isDragging,
dragHandleProps,
useNestable,
dragGestureHostActive,
creatingWorkspaceIds,
activeWorkspaceSelection,
hostBadgeByServerId,
@@ -1577,16 +1571,12 @@ function ProjectBlock({
selectionEnabled: boolean;
showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
onToggleCollapsed: (projectViewKey: string) => void;
onWorkspacePress?: () => void;
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
onWorktreeCreated?: (workspaceId: string) => void;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
useNestable: boolean;
dragGestureHostActive?: boolean;
creatingWorkspaceIds: ReadonlySet<string>;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
@@ -1594,12 +1584,20 @@ function ProjectBlock({
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const visibilityPolicy = useMemo(
() => ({
kind: "workspaces" as const,
getStatusBucket: (workspace: SidebarWorkspacePlacement) =>
workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket,
}),
[workspaceEntriesByKey],
);
const {
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces);
} = useLimitedSidebarGroup(project.workspaces, visibilityPolicy);
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
@@ -1628,16 +1626,10 @@ function ProjectBlock({
});
const renderWorkspaceRow = useCallback(
(
item: SidebarWorkspacePlacement,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
) => {
(item: SidebarWorkspacePlacement) => {
return (
<MemoWorkspaceRowItem
key={item.workspaceKey}
workspace={item}
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(item.serverId) ?? null}
@@ -1650,9 +1642,6 @@ function ProjectBlock({
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
);
},
@@ -1671,29 +1660,6 @@ function ProjectBlock({
],
);
const renderWorkspace = useCallback(
({
item,
drag: workspaceDrag,
isActive,
dragHandleProps: workspaceDragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return renderWorkspaceRow(item, {
drag: workspaceDrag,
isDragging: isActive,
dragHandleProps: workspaceDragHandleProps,
});
},
[renderWorkspaceRow],
);
const handleWorkspaceDragEnd = useCallback(
(workspaces: SidebarWorkspacePlacement[]) => {
onWorkspaceReorder(project.viewKey, workspaces);
},
[onWorkspaceReorder, project.viewKey],
);
const toast = useToast();
const { t } = useTranslation();
const [isRemovingProject, setIsRemovingProject] = useState(false);
@@ -1759,20 +1725,12 @@ function ProjectBlock({
if (project.workspaces.length > 0) {
projectChildren = (
<>
<DraggableList
<View
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
style={styles.workspaceListContainer}
>
{visibleWorkspaces.map(renderWorkspaceRow)}
</View>
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
@@ -1834,33 +1792,54 @@ type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
return (
previous.project === next.project &&
previous.workspaceEntriesByKey === next.workspaceEntriesByKey &&
projectWorkspaceEntriesEqual(previous, next) &&
previous.collapsed === next.collapsed &&
previous.displayName === next.displayName &&
previous.iconDataUri === next.iconDataUri &&
previous.selectionEnabled === next.selectionEnabled &&
previous.showShortcutBadges === next.showShortcutBadges &&
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
projectShortcutBadgesEqual(previous, next) &&
previous.hostBadgeByServerId === next.hostBadgeByServerId &&
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
previous.onWorktreeCreated === next.onWorktreeCreated &&
previous.drag === next.drag &&
previous.isDragging === next.isDragging &&
previous.dragHandleProps === next.dragHandleProps &&
previous.useNestable === next.useNestable &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId &&
areProjectBlockSelectionsEqual(previous, next)
);
}
function projectShortcutBadgesEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
if (
!next.showShortcutBadges ||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey
)
return true;
return next.project.workspaces.every(
({ workspaceKey }) =>
previous.shortcutIndexByWorkspaceKey.get(workspaceKey) ===
next.shortcutIndexByWorkspaceKey.get(workspaceKey),
);
}
function projectWorkspaceEntriesEqual(
previous: ProjectBlockProps,
next: ProjectBlockProps,
): boolean {
if (previous.workspaceEntriesByKey === next.workspaceEntriesByKey) return true;
return next.project.workspaces.every(
({ workspaceKey }) =>
previous.workspaceEntriesByKey.get(workspaceKey) ===
next.workspaceEntriesByKey.get(workspaceKey),
);
}
function areProjectBlockSelectionsEqual(
previous: ProjectBlockProps,
next: ProjectBlockProps,
@@ -2137,8 +2116,6 @@ function ProjectModeList({
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
const isWorkspaceRoute = useMemo(
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
@@ -2236,30 +2213,6 @@ function ProjectModeList({
[getProjectOrder, setProjectOrder],
);
const handleWorkspaceReorder = useCallback(
(projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey);
if (
!hasVisibleOrderChanged({
currentOrder: currentWorkspaceOrder,
reorderedVisibleKeys: reorderedWorkspaceKeys,
})
) {
return;
}
setWorkspaceOrder(
projectViewKey,
mergeWithRemainder({
currentOrder: currentWorkspaceOrder,
reorderedVisibleKeys: reorderedWorkspaceKeys,
}),
);
},
[getWorkspaceOrder, setWorkspaceOrder],
);
const handleWorktreeCreated = useCallback((workspaceId: string) => {
setCreatingWorkspaceIds((current) => {
const next = new Set(current);
@@ -2306,16 +2259,12 @@ function ProjectModeList({
selectionEnabled={selectionEnabled}
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
parentGestureRef={parentGestureRef}
onToggleCollapsed={onToggleProjectCollapsed}
onWorkspacePress={onWorkspacePress}
onWorkspaceReorder={handleWorkspaceReorder}
onWorktreeCreated={handleWorktreeCreated}
drag={dragState.drag}
isDragging={dragState.isDragging}
dragHandleProps={dragState.dragHandleProps}
useNestable={platformIsNative}
dragGestureHostActive={dragGestureHostActive}
creatingWorkspaceIds={creatingWorkspaceIds}
activeWorkspaceSelection={activeWorkspaceSelection}
hostBadgeByServerId={hostBadgeByServerId}
@@ -2329,15 +2278,12 @@ function ProjectModeList({
collapsedProjectKeys,
activeWorkspaceSelection,
handleWorktreeCreated,
handleWorkspaceReorder,
hostBadgeByServerId,
supportsMultiplicityByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,
dragGestureHostActive,
projectIconByProjectViewKey,
selectionEnabled,
shortcutIndexByWorkspaceKey,
@@ -167,7 +167,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
const hosts = useHosts();
// `allProjects`, never `projects`: the model's `projects` is already filtered, so a picker fed
// from it would lose the row that undoes the filter as soon as the filter narrowed to one.
const { allProjects, resolvedProjectFilters } = useSidebarModel();
const { allProjects, resolvedProjectFilters, workspaceSortSupported } = useSidebarModel();
const { labels } = useWorkspaceLabelProjection();
const [managerOpen, setManagerOpen] = useState(false);
const openManager = useCallback(() => setManagerOpen(true), []);
@@ -240,6 +240,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
},
];
if (preferences.grouping === "project") {
definitions.push({
id: "sort",
title: t("sidebar.display.sort.label"),
content: workspaceSortSupported ? (
<SortPage preferences={preferences} />
) : (
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
),
});
}
if (showHostFilter) {
definitions.push({
id: "hostFilter",
@@ -273,6 +285,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
}, [
t,
preferences,
workspaceSortSupported,
hosts,
showHostFilter,
showProjectFilter,
@@ -308,6 +321,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
>
{t("sidebar.display.grouping.label")}
</MenuSubTrigger>
{preferences.grouping === "project" ? (
<MenuSubTrigger
id="sort"
value={
workspaceSortSupported
? t(`sidebar.display.sort.${preferences.workspaceSort}`)
: undefined
}
>
{t("sidebar.display.sort.label")}
</MenuSubTrigger>
) : null}
<MenuSubTrigger
id="titleSource"
value={t(TITLE_SOURCE_LABEL_KEYS[preferences.titleSource])}
@@ -374,6 +399,23 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
);
}
function SortPage({ preferences }: { preferences: Preferences }): ReactElement {
const { t } = useTranslation();
const { setWorkspaceSort } = preferences;
const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]);
const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]);
return (
<>
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
{t("sidebar.display.sort.created")}
</MenuItem>
<MenuItem selected={preferences.workspaceSort === "lastPrompt"} onSelect={selectLastPrompt}>
{t("sidebar.display.sort.lastPrompt")}
</MenuItem>
</>
);
}
/**
* Every label you could filter by, wherever it lives, one row each.
*
@@ -1,3 +1,4 @@
import type { WorkspaceSort } from "@/projects/workspace-structure";
import { useCallback, useMemo } from "react";
import {
useAppSettings,
@@ -19,6 +20,8 @@ export interface SidebarDisplayPreferences {
showBackground: boolean;
toggleBackground: () => void;
grouping: SidebarGroupMode;
workspaceSort: WorkspaceSort;
setWorkspaceSort: (sort: WorkspaceSort) => void;
setGrouping: (mode: SidebarGroupMode) => void;
titleSource: WorkspaceTitleSource;
setTitleSource: (source: WorkspaceTitleSource) => void;
@@ -52,6 +55,8 @@ export interface SidebarDisplayPreferences {
export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
const showBackground = useSidebarViewStore((state) => state.showBackground);
const toggleBackground = useSidebarViewStore((state) => state.toggleBackground);
const workspaceSort = useSidebarViewStore((state) => state.workspaceSort);
const setWorkspaceSort = useSidebarViewStore((state) => state.setWorkspaceSort);
const grouping = useSidebarViewStore((state) => state.groupMode);
const setGrouping = useSidebarViewStore((state) => state.setGroupMode);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
@@ -111,6 +116,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
showBackground,
toggleBackground,
grouping,
workspaceSort,
setWorkspaceSort,
setGrouping,
titleSource: workspaceTitleSource,
setTitleSource,
@@ -134,6 +141,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
showBackground,
toggleBackground,
grouping,
workspaceSort,
setWorkspaceSort,
setGrouping,
workspaceTitleSource,
setTitleSource,
@@ -0,0 +1,21 @@
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
export type SidebarGroupVisibilityPolicy<T> =
| { kind: "pinned" }
| { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket };
export function selectSidebarGroupVisibility<T>(
items: readonly T[],
expanded: boolean,
policy: SidebarGroupVisibilityPolicy<T>,
) {
const limit = policy.kind === "pinned" ? 20 : 7;
const baseline = items.filter(
(item, index) =>
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"),
);
return {
visibleItems: expanded ? items.slice() : baseline,
canToggle: baseline.length < items.length,
};
}
@@ -134,7 +134,7 @@ export function SidebarModelProvider({
visibleWorkspaceKeys.has(workspace.workspaceKey),
);
return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0)
? [{ ...project, workspaces }]
? [workspaces.length === project.workspaces.length ? project : { ...project, workspaces }]
: [];
});
}
@@ -319,6 +319,11 @@ function StatusGroupList({
);
}
const STATUS_GROUP_VISIBILITY = {
kind: "workspaces" as const,
getStatusBucket: (workspace: SidebarWorkspaceEntry) => workspace.statusBucket,
};
function StatusGroupRows({
group,
collapsed,
@@ -345,7 +350,7 @@ function StatusGroupRows({
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(group.rows);
} = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY);
return (
<View style={collapsed ? undefined : styles.statusGroupBlockExpanded}>
@@ -41,8 +41,6 @@ import {
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
function noop() {}
interface SidebarWorkspaceRowProps {
workspace: SidebarWorkspaceEntry;
selected: boolean;
@@ -239,7 +237,7 @@ function WorkspaceRowBody({
const trailing = useSidebarWorkspaceTrailing();
const draggable = Boolean(drag);
const interaction = useLongPressDragInteraction({
drag: drag ?? noop,
drag,
menuController: null,
});
const {
@@ -1,15 +1,21 @@
import { useCallback, useMemo, useState } from "react";
import {
selectSidebarGroupVisibility,
type SidebarGroupVisibilityPolicy,
} from "./sidebar-group-visibility";
const INITIAL_VISIBLE_ITEMS = 20;
const PINNED_POLICY = { kind: "pinned" } as const;
export function useLimitedSidebarGroup<T>(items: readonly T[]) {
export function useLimitedSidebarGroup<T>(
items: readonly T[],
policy: SidebarGroupVisibilityPolicy<T> = PINNED_POLICY,
) {
const [expanded, setExpanded] = useState(false);
const visibleItems = useMemo(
() => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)),
[expanded, items],
const visibility = useMemo(
() => selectSidebarGroupVisibility(items, expanded, policy),
[expanded, items, policy],
);
const canToggle = items.length > INITIAL_VISIBLE_ITEMS;
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
return { visibleItems, expanded, canToggle, toggleExpanded };
return { ...visibility, expanded, toggleExpanded };
}
@@ -6,7 +6,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import type { useContextMenu } from "@/components/ui/context-menu";
export function useLongPressDragInteraction(input: {
drag: () => void;
drag?: () => void;
menuController: ReturnType<typeof useContextMenu> | null;
}) {
const { drag, menuController } = input;
@@ -59,7 +59,7 @@ export function useLongPressDragInteraction(input: {
const armTimers = useCallback(() => {
clearTimers();
if (platformIsWeb) {
if (platformIsWeb || !drag) {
return;
}
@@ -3,17 +3,18 @@ import { Pressable, Text, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
export interface TerminalPasteActionProps {
hasClipboardText: boolean;
/** Clipboard holds text or an image, so a paste attempt can do something. */
hasClipboardContent: boolean;
onPaste: () => void;
}
export function TerminalPasteAction({ hasClipboardText, onPaste }: TerminalPasteActionProps) {
export function TerminalPasteAction({ hasClipboardContent, onPaste }: TerminalPasteActionProps) {
return (
<TerminalActionButton
label="Paste"
accessibilityLabel="Paste"
testID="terminal-paste"
disabled={!hasClipboardText}
disabled={!hasClipboardContent}
onPress={onPaste}
variant="key"
/>
@@ -12,6 +12,7 @@ import type {
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
import type { TerminalFile } from "../terminal/file-paste";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
@@ -75,4 +76,5 @@ export interface TerminalEmulatorProps {
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
onFilePaste?: (file: Promise<TerminalFile | null>) => void;
}
@@ -1,715 +0,0 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ComponentProps,
} from "react";
import {
Keyboard,
StyleSheet,
View,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type {
TerminalOutputData,
TerminalFindResult,
} from "../terminal/runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { openExternalUrl } from "../utils/open-external-url";
import type { TerminalEmulatorHandle, TerminalEmulatorProps } from "./terminal-emulator-contract";
import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing";
import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness";
type BridgeInboundMessage =
| {
type: "mount";
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}
| { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "paste"; streamKey: string; text: string }
| { type: "clear"; streamKey: string }
| { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" }
| { type: "clearFind"; streamKey: string }
| { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } }
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
| { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean }
| { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
| {
type: "resolveLocalFileLinkResponse";
streamKey: string;
requestId: number;
target: TerminalLocalFileLinkTarget | null;
};
type BridgeOutboundMessage =
| { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string }
| { type: "findRequest"; streamKey: string }
| { type: "findResult"; streamKey: string; result: TerminalFindResult }
| {
type: "resize";
streamKey: string;
rows: number;
cols: number;
shouldClaim?: boolean;
forceClaim?: boolean;
}
| {
type: "terminalKey";
streamKey: string;
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
| { type: "pendingModifiersConsumed"; streamKey: string }
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
| { type: "openExternalUrl"; streamKey: string; url: string }
| {
type: "resolveLocalFileLink";
streamKey: string;
requestId: number;
source: TerminalLocalFileLinkSource;
}
| {
type: "openLocalFileLink";
streamKey: string;
target: TerminalLocalFileLinkTarget;
disposition: "main" | "side";
}
| { type: "swipeLeft"; streamKey: string }
| { type: "swipeRight"; streamKey: string }
| { type: "debug"; message: string; details?: unknown };
const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml };
const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"];
const BRIDGE_READY_TIMEOUT_MS = 2_500;
const RENDERER_READY_TIMEOUT_MS = 2_500;
const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8;
type WebViewProps = ComponentProps<typeof WebView>;
interface PendingTerminalTap {
startX: number;
startY: number;
moved: boolean;
}
function buildThemeKey(theme: ITheme): string {
return JSON.stringify(theme);
}
function serializeForInjectedJavaScript(message: BridgeInboundMessage): string {
return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
}
function createMountMessage(input: {
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}): BridgeInboundMessage {
return {
type: "mount",
streamKey: input.streamKey,
initialSnapshot: input.initialSnapshot,
scrollbackLines: input.scrollbackLines,
theme: input.theme,
fontFamily: input.fontFamily,
fontSize: input.fontSize,
pendingModifiers: input.pendingModifiers,
swipeGesturesEnabled: input.swipeGesturesEnabled,
};
}
export default function WebViewTerminalEmulator({
ref,
streamKey,
testId = "terminal-surface",
xtermTheme = {
background: "#0b0b0b",
foreground: "#e6e6e6",
cursor: "#e6e6e6",
},
scrollbackLines,
fontFamily,
fontSize,
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onFindRequest,
onFindResult,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
const webViewRef = useRef<WebView>(null);
const [webViewEpoch, setWebViewEpoch] = useState(0);
const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0);
const bridgeReadyRef = useRef(false);
const bridgeReadyVersionRef = useRef(0);
const rendererReadyVersionRef = useRef(0);
const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]);
const outputDecoderRef = useRef(new TextDecoder());
const mountRequestedStreamKeyRef = useRef<string | null>(null);
const rendererReadyStreamKeyRef = useRef<string | null>(null);
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingTapRef = useRef<PendingTerminalTap | null>(null);
const mountConfigRef = useRef({
streamKey,
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
fontFamily,
fontSize,
pendingModifiers,
swipeGesturesEnabled,
});
mountConfigRef.current = {
streamKey,
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
fontFamily,
fontSize,
pendingModifiers,
swipeGesturesEnabled,
};
const callbacksRef = useRef({
onFindRequest,
onFindResult,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onSwipeLeft,
onSwipeRight,
});
callbacksRef.current = {
onFindRequest,
onFindResult,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onSwipeLeft,
onSwipeRight,
};
const clearBridgeReadyTimeout = useCallback(() => {
if (bridgeReadyTimeoutRef.current === null) return;
clearTimeout(bridgeReadyTimeoutRef.current);
bridgeReadyTimeoutRef.current = null;
}, []);
const clearRendererReadyTimeout = useCallback(() => {
if (rendererReadyTimeoutRef.current === null) return;
clearTimeout(rendererReadyTimeoutRef.current);
rendererReadyTimeoutRef.current = null;
}, []);
const resetWebViewDocument = useCallback(() => {
clearBridgeReadyTimeout();
clearRendererReadyTimeout();
bridgeReadyRef.current = false;
pendingMessagesRef.current = [];
mountRequestedStreamKeyRef.current = null;
rendererReadyStreamKeyRef.current = null;
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
setWebViewEpoch((value) => value + 1);
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]);
const scheduleBridgeReadyWatchdog = useCallback(() => {
clearBridgeReadyTimeout();
const expectedBridgeReadyVersion = bridgeReadyVersionRef.current;
bridgeReadyTimeoutRef.current = setTimeout(() => {
bridgeReadyTimeoutRef.current = null;
if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) {
return;
}
resetWebViewDocument();
}, BRIDGE_READY_TIMEOUT_MS);
}, [clearBridgeReadyTimeout, resetWebViewDocument]);
const scheduleRendererReadyWatchdog = useCallback(() => {
clearRendererReadyTimeout();
const expectedRendererReadyVersion = rendererReadyVersionRef.current;
rendererReadyTimeoutRef.current = setTimeout(() => {
rendererReadyTimeoutRef.current = null;
if (
!shouldResetForMissingRendererReady({
expectedReadyVersion: expectedRendererReadyVersion,
currentReadyVersion: rendererReadyVersionRef.current,
expectedStreamKey: streamKey,
mountRequestedStreamKey: mountRequestedStreamKeyRef.current,
rendererReadyStreamKey: rendererReadyStreamKeyRef.current,
})
) {
return;
}
resetWebViewDocument();
}, RENDERER_READY_TIMEOUT_MS);
}, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]);
const flushPendingMessages = useCallback(() => {
if (!bridgeReadyRef.current || !webViewRef.current) return;
const pending = pendingMessagesRef.current.splice(0);
for (const message of pending) {
const payload = serializeForInjectedJavaScript(message);
webViewRef.current.injectJavaScript(
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
);
}
}, []);
const sendToWebView = useCallback((message: BridgeInboundMessage) => {
if (!bridgeReadyRef.current || !webViewRef.current) {
pendingMessagesRef.current.push(message);
return;
}
const payload = serializeForInjectedJavaScript(message);
webViewRef.current.injectJavaScript(
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
);
}, []);
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
find: {
setWidgetSize: (size) => sendToWebView({ type: "findWidgetSize", streamKey, size }),
search: (query, direction) => sendToWebView({ type: "find", streamKey, query, direction }),
clear: () => sendToWebView({ type: "clearFind", streamKey }),
},
writeOutput: (data: TerminalOutputData) => {
const output = outputDecoderRef.current.decode(data, { stream: true });
if (output.length === 0) {
return;
}
sendToWebView({ type: "writeOutput", streamKey, text: output });
},
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
const text = outputDecoderRef.current.decode(data, { stream: false });
if (text.length === 0) {
return;
}
sendToWebView({ type: "restoreOutput", streamKey, text });
},
renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
sendToWebView({ type: "renderSnapshot", streamKey, state });
},
paste: (text: string) => {
sendToWebView({ type: "paste", streamKey, text });
},
copySelection: async () => "",
clear: () => {
outputDecoderRef.current.decode();
sendToWebView({ type: "clear", streamKey });
},
claimSize: () => {
sendToWebView({ type: "resize", streamKey, forceClaim: true });
},
showKeyboard: () => {
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
webViewRef.current?.requestFocus();
},
blur: () => {
webViewRef.current?.injectJavaScript(
"window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;",
);
Keyboard.dismiss();
},
}),
[sendToWebView, streamKey],
);
useEffect(() => {
outputDecoderRef.current.decode();
}, [streamKey]);
useEffect(() => {
if (bridgeReadyVersion <= 0) return;
const mountMessage = createMountMessage(mountConfigRef.current);
mountRequestedStreamKeyRef.current = streamKey;
rendererReadyStreamKeyRef.current = null;
sendToWebView(mountMessage);
flushPendingMessages();
scheduleRendererReadyWatchdog();
}, [
bridgeReadyVersion,
flushPendingMessages,
scheduleRendererReadyWatchdog,
sendToWebView,
streamKey,
]);
const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]);
useEffect(() => {
if (!mountRequestedStreamKeyRef.current) return;
sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme });
}, [sendToWebView, streamKey, themeKey, xtermTheme]);
useEffect(() => {
if (!mountRequestedStreamKeyRef.current) return;
sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines });
}, [scrollbackLines, sendToWebView, streamKey]);
useEffect(() => {
if (!mountRequestedStreamKeyRef.current) return;
sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize });
}, [fontFamily, fontSize, sendToWebView, streamKey]);
useEffect(() => {
if (!mountRequestedStreamKeyRef.current) return;
sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers });
}, [pendingModifiers, sendToWebView, streamKey]);
useEffect(() => {
if (!mountRequestedStreamKeyRef.current) return;
sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled });
}, [sendToWebView, streamKey, swipeGesturesEnabled]);
useEffect(() => {
if (focusRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey, forceClaim: true });
sendToWebView({ type: "focus", streamKey });
webViewRef.current?.requestFocus();
}, [focusRequestToken, sendToWebView, streamKey]);
useEffect(() => {
if (resizeRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey, forceClaim: false, shouldClaim: false });
}, [resizeRequestToken, sendToWebView, streamKey]);
useEffect(() => {
return () => {
if (mountRequestedStreamKeyRef.current) {
const previousStreamKey = mountRequestedStreamKeyRef.current;
callbacksRef.current.onRendererReadyChange?.({
streamKey: previousStreamKey,
isReady: false,
});
sendToWebView({ type: "unmount", streamKey: previousStreamKey });
}
bridgeReadyRef.current = false;
pendingMessagesRef.current = [];
mountRequestedStreamKeyRef.current = null;
rendererReadyStreamKeyRef.current = null;
clearBridgeReadyTimeout();
clearRendererReadyTimeout();
};
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]);
const handleLifecycleMessage = useCallback(
(message: BridgeOutboundMessage): boolean => {
if (message.type === "bridgeReady") {
bridgeReadyRef.current = true;
bridgeReadyVersionRef.current += 1;
clearBridgeReadyTimeout();
setBridgeReadyVersion((value) => value + 1);
return true;
}
if (message.type === "rendererReady") {
if (message.streamKey === mountRequestedStreamKeyRef.current) {
rendererReadyStreamKeyRef.current = message.isReady ? message.streamKey : null;
if (message.isReady) {
rendererReadyVersionRef.current += 1;
clearRendererReadyTimeout();
}
}
callbacksRef.current.onRendererReadyChange?.({
streamKey: message.streamKey,
isReady: message.isReady,
});
return true;
}
return false;
},
[clearBridgeReadyTimeout, clearRendererReadyTimeout],
);
const resolveLocalFileLink = useCallback(
async (message: Extract<BridgeOutboundMessage, { type: "resolveLocalFileLink" }>) => {
try {
const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ??
null);
sendToWebView({
type: "resolveLocalFileLinkResponse",
streamKey: message.streamKey,
requestId: message.requestId,
target,
});
} catch {
sendToWebView({
type: "resolveLocalFileLinkResponse",
streamKey: message.streamKey,
requestId: message.requestId,
target: null,
});
}
},
[sendToWebView],
);
const handleTerminalMessage = useCallback(
(
message: Exclude<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
) => {
if (message.type === "resolveLocalFileLink") {
void resolveLocalFileLink(message);
return;
}
if (message.type === "openLocalFileLink") {
callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition);
return;
}
switch (message.type) {
case "input":
callbacksRef.current.onInput?.(message.data);
break;
case "resize":
callbacksRef.current.onResize?.({
rows: message.rows,
cols: message.cols,
shouldClaim: message.shouldClaim !== false,
forceClaim: message.forceClaim,
});
break;
case "terminalKey":
callbacksRef.current.onTerminalKey?.({
key: message.key,
ctrl: message.ctrl,
shift: message.shift,
alt: message.alt,
meta: message.meta,
});
break;
case "pendingModifiersConsumed":
callbacksRef.current.onPendingModifiersConsumed?.();
break;
case "inputModeChange":
callbacksRef.current.onInputModeChange?.(message.state);
break;
case "openExternalUrl":
void openExternalUrl(message.url);
break;
case "swipeLeft":
callbacksRef.current.onSwipeLeft?.();
break;
case "swipeRight":
callbacksRef.current.onSwipeRight?.();
break;
case "debug":
break;
}
},
[resolveLocalFileLink],
);
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let message: BridgeOutboundMessage;
try {
message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage;
} catch {
return;
}
if (message.type === "bridgeReady" || message.type === "rendererReady") {
handleLifecycleMessage(message);
return;
}
if (
!shouldHandleTerminalBridgeMessage(message, {
desiredStreamKey: streamKey,
mountedStreamKey: mountRequestedStreamKeyRef.current,
})
) {
return;
}
if (message.type === "findRequest") {
callbacksRef.current.onFindRequest?.();
return;
}
if (message.type === "findResult") {
callbacksRef.current.onFindResult?.(message.result);
return;
}
handleTerminalMessage(message);
},
[handleLifecycleMessage, handleTerminalMessage, streamKey],
);
const handleLoadStart = useCallback<NonNullable<WebViewProps["onLoadStart"]>>(() => {
bridgeReadyRef.current = false;
mountRequestedStreamKeyRef.current = null;
rendererReadyStreamKeyRef.current = null;
scheduleBridgeReadyWatchdog();
}, [scheduleBridgeReadyWatchdog]);
const handleContentProcessDidTerminate = useCallback<
NonNullable<WebViewProps["onContentProcessDidTerminate"]>
>(() => {
resetWebViewDocument();
}, [resetWebViewDocument]);
const handleRenderProcessGone = useCallback<
NonNullable<WebViewProps["onRenderProcessGone"]>
>(() => {
resetWebViewDocument();
}, [resetWebViewDocument]);
const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => {
pendingTapRef.current = {
startX: event.nativeEvent.pageX,
startY: event.nativeEvent.pageY,
moved: false,
};
}, []);
const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => {
const pendingTap = pendingTapRef.current;
if (!pendingTap) return;
const dx = event.nativeEvent.pageX - pendingTap.startX;
const dy = event.nativeEvent.pageY - pendingTap.startY;
if (
Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX ||
Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX
) {
pendingTap.moved = true;
}
}, []);
const handleWebViewTouchEnd = useCallback(() => {
const pendingTap = pendingTapRef.current;
pendingTapRef.current = null;
if (!pendingTap || pendingTap.moved) {
return;
}
webViewRef.current?.requestFocus();
callbacksRef.current.onFocus?.();
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
}, [sendToWebView, streamKey]);
const handleWebViewTouchCancel = useCallback(() => {
pendingTapRef.current = null;
}, []);
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
const rootStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.root, { backgroundColor }],
[backgroundColor],
);
const webViewStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.webView, { backgroundColor }],
[backgroundColor],
);
const webViewContainerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.webViewContainer, { backgroundColor }],
[backgroundColor],
);
return (
<View style={rootStyle} testID={testId}>
<WebView
key={webViewEpoch}
ref={webViewRef}
source={TERMINAL_WEBVIEW_SOURCE}
style={webViewStyle}
containerStyle={webViewContainerStyle}
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
scrollEnabled
nestedScrollEnabled
bounces={false}
overScrollMode="never"
keyboardDisplayRequiresUserAction={false}
automaticallyAdjustContentInsets={false}
contentInsetAdjustmentBehavior="never"
textInteractionEnabled
allowsLinkPreview={false}
setSupportMultipleWindows={false}
setBuiltInZoomControls={false}
setDisplayZoomControls={false}
textZoom={100}
onTouchStart={handleWebViewTouchStart}
onTouchMove={handleWebViewTouchMove}
onTouchEnd={handleWebViewTouchEnd}
onTouchCancel={handleWebViewTouchCancel}
onMessage={handleMessage}
onLoadStart={handleLoadStart}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
onRenderProcessGone={handleRenderProcessGone}
/>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
},
webView: {
flex: 1,
},
webViewContainer: {
flex: 1,
},
});
@@ -2,10 +2,8 @@ import { useEffect } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { useAppSettings } from "../hooks/use-settings";
import { resolveTerminalRendererSelection } from "../terminal/native-renderer/terminal-renderer-capability";
import NativeGridTerminalEmulator from "./terminal-emulator-native-grid.native";
import WebViewTerminalEmulator from "./terminal-emulator-webview.native";
import type { TerminalEmulatorProps } from "./terminal-emulator-contract";
export type { TerminalEmulatorHandle } from "./terminal-emulator-contract";
@@ -25,10 +23,7 @@ function UnsupportedNativeTerminal({ streamKey, onRendererReadyChange }: Termina
}
export default function TerminalEmulator(props: TerminalEmulatorProps) {
const { settings } = useAppSettings();
const { useLegacyTerminalRenderer } = settings;
const rendererSelection = resolveTerminalRendererSelection({
useLegacyTerminalRenderer,
supportsTerminalInputModeReplay: props.supportsTerminalInputModeReplay,
});
@@ -36,10 +31,7 @@ export default function TerminalEmulator(props: TerminalEmulatorProps) {
return <UnsupportedNativeTerminal {...props} />;
}
const Renderer =
rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator;
return <Renderer key={rendererSelection} {...props} />;
return <NativeGridTerminalEmulator {...props} />;
}
const styles = StyleSheet.create((theme) => ({
@@ -11,9 +11,7 @@ import {
useImperativeHandle,
useMemo,
useRef,
useState,
type CSSProperties,
type DragEvent as ReactDragEvent,
type MouseEvent as ReactMouseEvent,
type Ref,
} from "react";
@@ -29,6 +27,7 @@ import {
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
import type { TerminalFile } from "../terminal/file-paste";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
@@ -37,13 +36,6 @@ import type { TerminalClipboardWriter } from "../terminal/native-renderer/termin
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
import {
extractTerminalDropPaths,
isTerminalDragLeaveOutside,
isTerminalFileDrag,
prepareDroppedPathsForTerminal,
} from "../terminal/drop/terminal-file-drop";
import { getDesktopHost } from "@/desktop/host";
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
@@ -145,6 +137,7 @@ interface TerminalEmulatorProps {
disposition: "main" | "side",
) => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
onFilePaste?: (file: Promise<TerminalFile | null>) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
@@ -191,6 +184,7 @@ export default function TerminalEmulator({
onResolveLocalFileLink,
onOpenLocalFileLink,
onRendererReadyChange,
onFilePaste,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
@@ -220,6 +214,7 @@ export default function TerminalEmulator({
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onFilePaste,
});
mountCallbacksRef.current = {
onFindRequest,
@@ -231,13 +226,12 @@ export default function TerminalEmulator({
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onFilePaste,
};
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
const pendingModifiersRef = useRef(pendingModifiers);
pendingModifiersRef.current = pendingModifiers;
const [isDropActive, setIsDropActive] = useState(false);
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
const pasteText = useCallback((text: string) => {
@@ -506,11 +500,13 @@ export default function TerminalEmulator({
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onFilePaste,
onOpenExternalUrl: openExternalUrl,
},
});
}, [
onFindRequest,
onFilePaste,
onFindResult,
onInput,
onInputModeChange,
@@ -582,111 +578,6 @@ export default function TerminalEmulator({
[showTerminalContextMenu],
);
const clearDropActiveTimeout = useCallback(() => {
if (dropActiveTimeoutRef.current === null) {
return;
}
clearTimeout(dropActiveTimeoutRef.current);
dropActiveTimeoutRef.current = null;
}, []);
const clearTerminalDropActive = useCallback(() => {
clearDropActiveTimeout();
setIsDropActive(false);
}, [clearDropActiveTimeout]);
const keepTerminalDropActive = useCallback(() => {
clearDropActiveTimeout();
setIsDropActive(true);
dropActiveTimeoutRef.current = setTimeout(() => {
dropActiveTimeoutRef.current = null;
setIsDropActive(false);
}, 180);
}, [clearDropActiveTimeout]);
useEffect(() => {
const root = rootRef.current;
if (!root) {
return () => {};
}
const handleDragEnter = (event: DragEvent) => {
if (!isTerminalFileDrag(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
keepTerminalDropActive();
};
const handleDragOver = (event: DragEvent) => {
if (!isTerminalFileDrag(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy";
}
keepTerminalDropActive();
};
const handleDrop = (event: DragEvent) => {
if (!isTerminalFileDrag(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
clearTerminalDropActive();
const bridge = getDesktopHost();
const paths = extractTerminalDropPaths(event.dataTransfer, bridge);
if (paths.length === 0) {
return;
}
runtimeRef.current?.focus();
mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths, bridge));
};
root.addEventListener("dragenter", handleDragEnter, { capture: true });
root.addEventListener("dragover", handleDragOver, { capture: true });
root.addEventListener("drop", handleDrop, { capture: true });
window.addEventListener("dragend", clearTerminalDropActive);
window.addEventListener("drop", clearTerminalDropActive);
return () => {
root.removeEventListener("dragenter", handleDragEnter, { capture: true });
root.removeEventListener("dragover", handleDragOver, { capture: true });
root.removeEventListener("drop", handleDrop, { capture: true });
window.removeEventListener("dragend", clearTerminalDropActive);
window.removeEventListener("drop", clearTerminalDropActive);
clearDropActiveTimeout();
};
}, [clearDropActiveTimeout, clearTerminalDropActive, keepTerminalDropActive]);
const handleRootDragLeave = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (!isTerminalFileDrag(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (
!isTerminalDragLeaveOutside({
currentTarget: event.currentTarget,
relatedTarget: event.relatedTarget,
})
) {
return;
}
clearTerminalDropActive();
},
[clearTerminalDropActive],
);
const rootDivStyle = useMemo<CSSProperties>(
() => ({
position: "relative",
@@ -702,19 +593,6 @@ export default function TerminalEmulator({
}),
[xtermTheme.background],
);
const dropOverlayStyle = useMemo<CSSProperties>(
() => ({
position: "absolute",
inset: 0,
zIndex: 9,
border: "1px solid rgba(78, 161, 255, 0.72)",
backgroundColor: "rgba(78, 161, 255, 0.16)",
opacity: isDropActive ? 1 : 0,
pointerEvents: "none",
transition: "opacity 120ms ease-out",
}),
[isDropActive],
);
return (
<div
ref={rootRef}
@@ -723,10 +601,8 @@ export default function TerminalEmulator({
style={rootDivStyle}
onPointerDown={handleRootPointerDown}
onContextMenu={handleRootContextMenu}
onDragLeave={handleRootDragLeave}
>
<div ref={hostRef} style={HOST_DIV_STYLE} />
<div style={dropOverlayStyle} />
</div>
);
}
+146 -9
View File
@@ -43,7 +43,23 @@ import {
shouldShowTerminalPasteAction,
type TerminalVirtualKeyboardControl,
} from "@/terminal/runtime/terminal-virtual-keyboard";
import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
import { encodeTerminalPaste, pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
import {
TerminalFilePasteQueue,
TerminalFileTooLargeError,
terminalFileFromBase64,
type TerminalFile,
} from "@/terminal/file-paste";
import { useToast } from "@/contexts/toast-context";
import type { DismissToast } from "@/components/toast-host";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useFileDrop } from "@/components/file-drop/use-file-drop";
import type { DroppedItem } from "@/components/file-drop/types";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { getDesktopHost } from "@/desktop/host";
import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
import { toErrorMessage } from "@/utils/error-messages";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
EMPTY_FOCUS_CLAIM_STATE,
@@ -122,6 +138,24 @@ type PendingTerminalInput =
input: TerminalKeyInput;
};
async function readNativeClipboardImage(): Promise<TerminalFile | null> {
const image = await Clipboard.getImageAsync({ format: "png" });
if (!image) {
return null;
}
return terminalFileFromBase64({
fileName: "clipboard.png",
mimeType: "image/png",
data: image.data.slice(image.data.indexOf(",") + 1),
});
}
/** Receives files dropped on the pane's FileDropZone; the pane routes them by host locality. */
function TerminalFileDropSink({ onFiles }: { onFiles: (items: DroppedItem[]) => void }) {
useFileDrop({ onGenericFiles: onFiles });
return null;
}
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
@@ -292,9 +326,47 @@ export function TerminalPane({
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [hasSelection, setHasSelection] = useState(false);
const [hasClipboardText, setHasClipboardText] = useState(false);
const [hasClipboardImage, setHasClipboardImage] = useState(false);
const [isKeyboardToggleVisible, setIsKeyboardToggleVisible] = useState(false);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const toast = useToast();
const dismissUploadingToastRef = useRef<DismissToast | null>(null);
const showFileUploading = useStableEvent((uploading: boolean) => {
dismissUploadingToastRef.current?.();
dismissUploadingToastRef.current = uploading
? toast.show(t("workspace.terminal.uploadingFile"), {
durationMs: null,
testID: "terminal-file-uploading",
})
: null;
});
const reportFileUploaded = useStableEvent(() => {
toast.show(t("workspace.terminal.fileUploaded"), { variant: "success" });
});
const reportFilePasteFailure = useStableEvent((error: unknown) => {
const reason =
error instanceof TerminalFileTooLargeError
? t("composer.errors.fileTooLarge", { size: "50MB", fileName: error.fileName })
: toErrorMessage(error);
toast.error(t("workspace.terminal.pasteFileFailed", { reason }));
});
const [filePastes] = useState(
() =>
new TerminalFilePasteQueue({
onUploadingChange: showFileUploading,
onUploaded: reportFileUploaded,
onFailure: reportFilePasteFailure,
}),
);
useEffect(() => () => void filePastes.cancel(), [filePastes, terminalStreamKey]);
useEffect(() => {
if (!isConnected && filePastes.cancel()) {
reportFilePasteFailure(new Error(t("workspace.terminal.hostDisconnected")));
}
}, [filePastes, isConnected, reportFilePasteFailure, t]);
// Only the desktop app's own daemon can read files on this machine by path.
const isLocalHost = useIsLocalDaemon(serverId);
useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection);
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const findRef = useRef<TerminalPaneFindHandle>(null);
@@ -325,10 +397,15 @@ export function TerminalPane({
return;
}
try {
const hasText = await Clipboard.hasStringAsync();
const [hasText, hasImage] = await Promise.all([
Clipboard.hasStringAsync(),
Clipboard.hasImageAsync(),
]);
setHasClipboardText(hasText);
setHasClipboardImage(hasImage);
} catch {
setHasClipboardText(false);
setHasClipboardImage(false);
}
}, [showVirtualKeyBar]);
@@ -705,6 +782,9 @@ export function TerminalPane({
alt: boolean;
meta?: boolean;
}): boolean => {
if (input.ctrl && input.key.toLowerCase() === "c") {
filePastes.cancel();
}
if (!client || !terminalIdRef.current) {
enqueuePendingTerminalInput({
type: "key",
@@ -740,7 +820,7 @@ export function TerminalPane({
}
return true;
},
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput],
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, filePastes],
);
const handleTerminalData = useCallback(
@@ -748,6 +828,10 @@ export function TerminalPane({
if (data.length === 0) {
return;
}
// Ctrl+C abandons file pastes that have not landed yet.
if (data.includes("\x03")) {
filePastes.cancel();
}
if (hasPendingTerminalModifiers(modifiers)) {
const pendingResolution = resolvePendingModifierDataInput({
@@ -793,12 +877,59 @@ export function TerminalPane({
clearPendingModifiers,
client,
dispatchTerminalInputEntry,
filePastes,
modifiers,
sendTerminalKey,
enqueuePendingTerminalInput,
],
);
const pasteTerminalText = useStableEvent((text: string) => {
const entry: PendingTerminalInput = {
type: "data",
data: encodeTerminalPaste({
text,
bracketedPaste: inputModeRef.current.bracketedPaste ?? false,
}),
};
if (!dispatchTerminalInputEntry(entry)) {
enqueuePendingTerminalInput(entry);
}
});
const handleFilePaste = useStableEvent((file: Promise<TerminalFile | null>) => {
filePastes.add(file, {
upload: async (upload) => {
if (!client || !isConnected) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const response = await client.uploadFile(upload);
if (!response.file) {
throw new Error(response.error ?? "Upload failed");
}
return response.file.path;
},
paste: pasteTerminalText,
});
});
const handleDroppedFiles = useStableEvent((items: DroppedItem[]) => {
const bridge = getDesktopHost();
const paths: string[] = [];
for (const item of items) {
const delivery = routeTerminalDrop(item, { hostIsLocal: isLocalHost, bridge });
if (delivery.kind === "path") {
paths.push(delivery.path);
} else {
handleFilePaste(delivery.read());
}
}
if (paths.length > 0) {
pasteTerminalText(prepareDroppedPathsForTerminal(paths));
}
requestTerminalFocus();
});
const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => {
const nextSize = { rows: input.rows, cols: input.cols };
const claim = resolveTerminalResizeClaim({
@@ -876,11 +1007,15 @@ export function TerminalPane({
const handleTerminalPaste = useCallback(() => {
requestTerminalReflow();
void pasteTerminalClipboard({
const image = pasteTerminalClipboard({
clipboard: { readText: () => Clipboard.getStringAsync() },
terminal: { paste: (text) => emulatorRef.current?.paste(text) },
}).then(() => refreshClipboardAvailability());
}, [requestTerminalReflow, refreshClipboardAvailability]);
})
.catch(() => false)
.then((pastedText) => (pastedText ? null : readNativeClipboardImage()));
handleFilePaste(image);
void image.catch(() => null).then(() => refreshClipboardAvailability());
}, [handleFilePaste, requestTerminalReflow, refreshClipboardAvailability]);
const handleTerminalCopy = useCallback(() => {
void emulatorRef.current
@@ -1032,7 +1167,7 @@ export function TerminalPane({
return showPasteAction ? (
<TerminalPasteAction
key={controlId}
hasClipboardText={hasClipboardText}
hasClipboardContent={hasClipboardText || hasClipboardImage}
onPaste={handleTerminalPaste}
/>
) : null;
@@ -1065,7 +1200,8 @@ export function TerminalPane({
return (
<Animated.View style={containerStyle}>
<View style={styles.outputContainer}>
<FileDropZone style={styles.outputContainer}>
<TerminalFileDropSink onFiles={handleDroppedFiles} />
<View style={styles.terminalGestureContainer}>
<TerminalEmulator
ref={emulatorRef}
@@ -1090,6 +1226,7 @@ export function TerminalPane({
onFocus={handleTerminalFocus}
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onFilePaste={handleFilePaste}
onInputModeChange={handleInputModeChange}
onSelectionChange={handleSelectionChange}
onResolveLocalFileLink={handleResolveLocalFileLink}
@@ -1122,7 +1259,7 @@ export function TerminalPane({
<TerminalFloatingCopyAction hasSelection={hasSelection} onCopy={handleTerminalCopy} />
</View>
) : null}
</View>
</FileDropZone>
{streamError ? (
<View style={styles.errorRow}>
@@ -1,43 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing";
describe("terminal WebView message routing", () => {
it("rejects terminal messages from a stale stream", () => {
expect(
shouldHandleTerminalBridgeMessage(
{ type: "input", streamKey: "terminal-a" },
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
),
).toBe(false);
expect(
shouldHandleTerminalBridgeMessage(
{ type: "resize", streamKey: "terminal-a" },
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
),
).toBe(false);
});
it("rejects the old WebView stream while React is switching callbacks", () => {
expect(
shouldHandleTerminalBridgeMessage(
{ type: "input", streamKey: "terminal-a" },
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-a" },
),
).toBe(false);
});
it("accepts messages from the mounted stream and unscoped diagnostics", () => {
expect(
shouldHandleTerminalBridgeMessage(
{ type: "input", streamKey: "terminal-b" },
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
),
).toBe(true);
expect(
shouldHandleTerminalBridgeMessage(
{ type: "debug" },
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
),
).toBe(true);
});
});
@@ -1,17 +0,0 @@
interface TerminalBridgeMessageIdentity {
type: string;
streamKey?: string;
}
export function shouldHandleTerminalBridgeMessage(
message: TerminalBridgeMessageIdentity,
stream: {
desiredStreamKey: string;
mountedStreamKey: string | null;
},
): boolean {
return (
message.streamKey === undefined ||
(message.streamKey === stream.desiredStreamKey && message.streamKey === stream.mountedStreamKey)
);
}
@@ -1,64 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness";
describe("legacy terminal WebView readiness", () => {
it("resets when a requested mount never reports renderer readiness", () => {
expect(
shouldResetForMissingRendererReady({
expectedReadyVersion: 0,
currentReadyVersion: 0,
expectedStreamKey: "terminal-a",
mountRequestedStreamKey: "terminal-a",
rendererReadyStreamKey: null,
}),
).toBe(true);
});
it("keeps the document after the requested renderer confirms readiness", () => {
expect(
shouldResetForMissingRendererReady({
expectedReadyVersion: 0,
currentReadyVersion: 0,
expectedStreamKey: "terminal-a",
mountRequestedStreamKey: "terminal-a",
rendererReadyStreamKey: "terminal-a",
}),
).toBe(false);
});
it("ignores a watchdog from an earlier ready generation", () => {
expect(
shouldResetForMissingRendererReady({
expectedReadyVersion: 0,
currentReadyVersion: 1,
expectedStreamKey: "terminal-a",
mountRequestedStreamKey: "terminal-a",
rendererReadyStreamKey: null,
}),
).toBe(false);
});
it("does not let readiness from a stale stream satisfy the requested mount", () => {
expect(
shouldResetForMissingRendererReady({
expectedReadyVersion: 0,
currentReadyVersion: 0,
expectedStreamKey: "terminal-b",
mountRequestedStreamKey: "terminal-b",
rendererReadyStreamKey: "terminal-a",
}),
).toBe(true);
});
it("does not reset a newer mount from an expired watchdog", () => {
expect(
shouldResetForMissingRendererReady({
expectedReadyVersion: 0,
currentReadyVersion: 0,
expectedStreamKey: "terminal-a",
mountRequestedStreamKey: "terminal-b",
rendererReadyStreamKey: null,
}),
).toBe(false);
});
});
@@ -1,13 +0,0 @@
export function shouldResetForMissingRendererReady(input: {
expectedReadyVersion: number;
currentReadyVersion: number;
expectedStreamKey: string;
mountRequestedStreamKey: string | null;
rendererReadyStreamKey: string | null;
}): boolean {
return (
input.currentReadyVersion === input.expectedReadyVersion &&
input.mountRequestedStreamKey === input.expectedStreamKey &&
input.rendererReadyStreamKey !== input.expectedStreamKey
);
}
@@ -0,0 +1,35 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useToastHost } from "./toast-host";
describe("useToastHost", () => {
it("dismisses the toast that show returned the dismiss for", () => {
const { result } = renderHook(() => useToastHost());
let dismiss = () => {};
act(() => {
dismiss = result.current.api.show("Uploading image…", { durationMs: null });
});
expect(result.current.toast?.content).toBe("Uploading image…");
act(() => dismiss());
expect(result.current.toast).toBeNull();
});
it("leaves a newer toast in place when an older one is dismissed", () => {
const { result } = renderHook(() => useToastHost());
let dismissUploading = () => {};
act(() => {
dismissUploading = result.current.api.show("Uploading image…", { durationMs: null });
});
act(() => {
result.current.api.show("Image uploaded", { variant: "success" });
});
act(() => dismissUploading());
expect(result.current.toast?.content).toBe("Image uploaded");
});
});
+11 -5
View File
@@ -43,8 +43,11 @@ export interface ToastState {
testID?: string;
}
/** Dismisses the toast it was returned for; a no-op once another toast replaced it. */
export type DismissToast = () => void;
export interface ToastApi {
show: (content: ReactNode, options?: ToastShowOptions) => void;
show: (content: ReactNode, options?: ToastShowOptions) => DismissToast;
copied: (label?: string) => void;
error: (message: string) => void;
}
@@ -52,6 +55,7 @@ export interface ToastApi {
type ToastViewportPlacement = "app-shell" | "panel";
const DEFAULT_DURATION_MS = 2200;
const noopDismiss: DismissToast = () => {};
const TOAST_MAX_WIDTH = 480;
const toastEntering = FadeIn.duration(140);
const toastExiting = FadeOut.duration(140);
@@ -73,10 +77,10 @@ export function useToastHost(): {
const [toast, setToast] = useState<ToastState | null>(null);
const idRef = useRef(0);
const show = useCallback((content: ReactNode, options?: ToastShowOptions) => {
const show = useCallback((content: ReactNode, options?: ToastShowOptions): DismissToast => {
const nativeMessage = typeof content === "string" ? content.trim() : null;
if (!content || nativeMessage === "") {
return;
return noopDismiss;
}
const variant = options?.variant ?? "default";
@@ -87,12 +91,13 @@ export function useToastHost(): {
const duration =
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
return;
return noopDismiss;
}
idRef.current += 1;
const id = idRef.current;
setToast({
id: idRef.current,
id,
content,
nativeMessage,
icon: options?.icon,
@@ -100,6 +105,7 @@ export function useToastHost(): {
durationMs,
testID: options?.testID,
});
return () => setToast((current) => (current?.id === id ? null : current));
}, []);
const api = useMemo<ToastApi>(
@@ -31,6 +31,8 @@ function workspaceDescriptor(input: {
statusEnteredAt?: Date | null;
}): WorkspaceDescriptor {
return {
createdAt: null,
lastUserMessageAt: null,
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
@@ -86,7 +88,6 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
});
useSidebarOrderStore.setState({
projectOrder: [],
workspaceOrderByProject: {},
});
useSidebarViewStore.setState({
groupMode: "project",
+2 -1
View File
@@ -1,4 +1,5 @@
import type { SelectedFile } from "@/attachments/selected-file";
import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type {
@@ -131,7 +132,7 @@ export async function uploadFileAttachments(input: {
for (const file of input.files) {
const bytes = await file.readBytes();
if (bytes.byteLength > 50 * 1024 * 1024) {
if (bytes.byteLength > FILE_UPLOAD_MAX_BYTES) {
throw new Error(
i18n.t("composer.errors.fileTooLarge", { size: "50MB", fileName: file.fileName }),
);
@@ -11,6 +11,8 @@ const SERVER_ID = "diff-stat-pill";
const WORKSPACE_ID = "workspace";
const workspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: WORKSPACE_ID,
projectId: "project",
projectDisplayName: "Project",
@@ -8,6 +8,8 @@ import {
} from "@/contexts/session-workspace-upserts";
const baseWorkspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "/repo/worktree",
projectId: "/repo",
projectDisplayName: "Repo",
@@ -38,3 +38,5 @@ export function addNotificationResponseReceivedListener() {
export async function getLastNotificationResponseAsync() {
return null;
}
export function clearLastNotificationResponse(): void {}
-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,
},
}));

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