mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc97889229 | ||
|
|
c3c6a936a4 | ||
|
|
12e7a448f0 | ||
|
|
4574142314 | ||
|
|
4673ab1903 | ||
|
|
76b32544c6 | ||
|
|
bdae614e95 | ||
|
|
bad132884d | ||
|
|
68fa464cdf |
@@ -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}"
|
||||
|
||||
@@ -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 项修复。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+1
-3
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+4
-4
@@ -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
@@ -1 +1 @@
|
||||
sha256-La6xekVRQ9F6UulA6OHX5SzaXv6L0qZ2KpGkRScFd8Q=
|
||||
sha256-DkB70/NH4SriKZ6hMDhI7XfwkqjGfb9e1zC3dxjBaTg=
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,6 +22,22 @@ export async function openCommandCenterForAgent(
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -38,3 +38,5 @@ export function addNotificationResponseReceivedListener() {
|
||||
export async function getLastNotificationResponseAsync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearLastNotificationResponse(): void {}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -1928,7 +1928,6 @@ export const ar: TranslationResources = {
|
||||
alwaysUseExtension: "استخدام دائمًا لـ .{{extension}}",
|
||||
alwaysUseFilename: "استخدام دائمًا لـ {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "إجراءات الملف",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1947,6 +1946,8 @@ export const ar: TranslationResources = {
|
||||
notFound: "الملف غير موجود",
|
||||
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
||||
editor: {
|
||||
preview: "معاينة",
|
||||
source: "المصدر",
|
||||
fileSize: "حجم الملف {{size}}",
|
||||
lines: "{{count}} سطر",
|
||||
editorStatus: "حالة المحرر {{status}}",
|
||||
@@ -2252,7 +2253,7 @@ export const ar: TranslationResources = {
|
||||
automaticHint: "يختار Paseo نموذجًا سريعًا متاحًا",
|
||||
preferredHint: "اختر النموذج الذي يستخدمه Paseo",
|
||||
model: "النموذج",
|
||||
fallbackHint: "إذا لم يكن متاحًا، يستخدم Paseo نموذجًا آخر متاحًا",
|
||||
modelHint: "يُستخدم هذا النموذج فقط. إذا فشل، لا يجرّب Paseo نموذجًا آخر",
|
||||
docs: "الوثائق",
|
||||
saveError: "تعذر تحديث إنشاء البيانات الوصفية",
|
||||
},
|
||||
|
||||
@@ -1953,7 +1953,6 @@ export const en = {
|
||||
alwaysUseExtension: "Always use for .{{extension}}",
|
||||
alwaysUseFilename: "Always use for {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "File actions",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1972,6 +1971,8 @@ export const en = {
|
||||
notFound: "File not found",
|
||||
failedToLoadPreview: "Failed to load file preview",
|
||||
editor: {
|
||||
preview: "Preview",
|
||||
source: "Source",
|
||||
fileSize: "File size {{size}}",
|
||||
lines: "{{count}} lines",
|
||||
editorStatus: "Editor status {{status}}",
|
||||
@@ -2341,7 +2342,7 @@ export const en = {
|
||||
automaticHint: "Paseo picks a fast available model",
|
||||
preferredHint: "Choose the model Paseo uses",
|
||||
model: "Model",
|
||||
fallbackHint: "If it is unavailable, Paseo falls back to another available model",
|
||||
modelHint: "Only this model is used. If it fails, Paseo does not try another model",
|
||||
docs: "Docs",
|
||||
saveError: "Unable to update metadata generation",
|
||||
},
|
||||
|
||||
@@ -1976,7 +1976,6 @@ export const es: TranslationResources = {
|
||||
alwaysUseExtension: "Usar siempre para .{{extension}}",
|
||||
alwaysUseFilename: "Usar siempre para {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "Acciones del archivo",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1995,6 +1994,8 @@ export const es: TranslationResources = {
|
||||
notFound: "No se encontró el archivo",
|
||||
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
||||
editor: {
|
||||
preview: "Vista previa",
|
||||
source: "Código fuente",
|
||||
fileSize: "Tamaño {{size}}",
|
||||
lines: "{{count}} líneas",
|
||||
editorStatus: "Estado del editor: {{status}}",
|
||||
@@ -2264,7 +2265,7 @@ export const es: TranslationResources = {
|
||||
automaticHint: "Paseo elige un modelo rápido disponible",
|
||||
preferredHint: "Elige el modelo que usa Paseo",
|
||||
model: "Modelo",
|
||||
fallbackHint: "Si no está disponible, Paseo usa otro modelo disponible",
|
||||
modelHint: "Solo se usa este modelo. Si falla, Paseo no prueba otro modelo",
|
||||
docs: "Documentación",
|
||||
saveError: "No se pudo actualizar la generación de metadatos",
|
||||
},
|
||||
|
||||
@@ -1974,7 +1974,6 @@ export const fr: TranslationResources = {
|
||||
alwaysUseExtension: "Toujours utiliser pour .{{extension}}",
|
||||
alwaysUseFilename: "Toujours utiliser pour {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "Actions du fichier",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1993,6 +1992,8 @@ export const fr: TranslationResources = {
|
||||
notFound: "Fichier introuvable",
|
||||
failedToLoadPreview: "Impossible de charger l’aperçu du fichier",
|
||||
editor: {
|
||||
preview: "Aperçu",
|
||||
source: "Source",
|
||||
fileSize: "Taille du fichier : {{size}}",
|
||||
lines: "{{count}} lignes",
|
||||
editorStatus: "État de l’éditeur : {{status}}",
|
||||
@@ -2264,7 +2265,7 @@ export const fr: TranslationResources = {
|
||||
automaticHint: "Paseo choisit un modèle rapide disponible",
|
||||
preferredHint: "Choisissez le modèle utilisé par Paseo",
|
||||
model: "Modèle",
|
||||
fallbackHint: "S’il est indisponible, Paseo utilise un autre modèle disponible",
|
||||
modelHint: "Seul ce modèle est utilisé. S’il échoue, Paseo n’essaie pas d’autre modèle",
|
||||
docs: "Documentation",
|
||||
saveError: "Impossible de mettre à jour la génération de métadonnées",
|
||||
},
|
||||
|
||||
@@ -1947,7 +1947,6 @@ export const ja: TranslationResources = {
|
||||
alwaysUseExtension: ".{{extension}} に常に使用",
|
||||
alwaysUseFilename: "{{filename}} に常に使用",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "ファイルの操作",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1966,6 +1965,8 @@ export const ja: TranslationResources = {
|
||||
notFound: "ファイルが見つかりません",
|
||||
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
||||
editor: {
|
||||
preview: "プレビュー",
|
||||
source: "ソース",
|
||||
fileSize: "ファイルサイズ {{size}}",
|
||||
lines: "{{count}} 行",
|
||||
editorStatus: "エディターの状態 {{status}}",
|
||||
@@ -2237,7 +2238,7 @@ export const ja: TranslationResources = {
|
||||
automaticHint: "Paseo が利用可能な高速モデルを選択します",
|
||||
preferredHint: "Paseo が使用するモデルを選択します",
|
||||
model: "モデル",
|
||||
fallbackHint: "利用できない場合、Paseo は別の利用可能なモデルを使用します",
|
||||
modelHint: "このモデルのみを使用します。失敗しても、Paseo は別のモデルを試しません",
|
||||
docs: "ドキュメント",
|
||||
saveError: "メタデータ生成を更新できません",
|
||||
},
|
||||
|
||||
@@ -1938,7 +1938,6 @@ export const ko: TranslationResources = {
|
||||
alwaysUseExtension: ".{{extension}}에 항상 사용",
|
||||
alwaysUseFilename: "{{filename}}에 항상 사용",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "파일 작업",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1957,6 +1956,8 @@ export const ko: TranslationResources = {
|
||||
notFound: "파일을 찾을 수 없습니다",
|
||||
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
|
||||
editor: {
|
||||
preview: "미리보기",
|
||||
source: "소스",
|
||||
fileSize: "파일 크기 {{size}}",
|
||||
lines: "{{count}} 라인",
|
||||
editorStatus: "편집기 상태 {{status}}",
|
||||
@@ -2226,7 +2227,7 @@ export const ko: TranslationResources = {
|
||||
automaticHint: "Paseo가 사용 가능한 빠른 모델을 선택합니다",
|
||||
preferredHint: "Paseo에서 사용할 모델을 선택하세요",
|
||||
model: "모델",
|
||||
fallbackHint: "사용할 수 없으면 Paseo가 다른 사용 가능한 모델을 사용합니다",
|
||||
modelHint: "이 모델만 사용합니다. 실패해도 Paseo는 다른 모델을 시도하지 않습니다",
|
||||
docs: "문서",
|
||||
saveError: "메타데이터 생성을 업데이트할 수 없습니다",
|
||||
},
|
||||
|
||||
@@ -1961,7 +1961,6 @@ export const ptBR: TranslationResources = {
|
||||
alwaysUseExtension: "Sempre usar para .{{extension}}",
|
||||
alwaysUseFilename: "Sempre usar para {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "Ações do arquivo",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1980,6 +1979,8 @@ export const ptBR: TranslationResources = {
|
||||
notFound: "Arquivo não encontrado",
|
||||
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
||||
editor: {
|
||||
preview: "Prévia",
|
||||
source: "Fonte",
|
||||
fileSize: "Tamanho {{size}}",
|
||||
lines: "{{count}} linhas",
|
||||
editorStatus: "Status do editor: {{status}}",
|
||||
@@ -2249,7 +2250,7 @@ export const ptBR: TranslationResources = {
|
||||
automaticHint: "O Paseo escolhe um modelo rápido disponível",
|
||||
preferredHint: "Escolha o modelo que o Paseo usa",
|
||||
model: "Modelo",
|
||||
fallbackHint: "Se ele não estiver disponível, o Paseo usa outro modelo disponível",
|
||||
modelHint: "Somente este modelo é usado. Se ele falhar, o Paseo não tenta outro modelo",
|
||||
docs: "Documentação",
|
||||
saveError: "Não foi possível atualizar a geração de metadados",
|
||||
},
|
||||
|
||||
@@ -1960,7 +1960,6 @@ export const ru: TranslationResources = {
|
||||
alwaysUseExtension: "Всегда использовать для .{{extension}}",
|
||||
alwaysUseFilename: "Всегда использовать для {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "Действия с файлом",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1979,6 +1978,8 @@ export const ru: TranslationResources = {
|
||||
notFound: "Файл не найден",
|
||||
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
||||
editor: {
|
||||
preview: "Предпросмотр",
|
||||
source: "Исходный текст",
|
||||
fileSize: "Размер файла {{size}}",
|
||||
lines: "Строк: {{count}}",
|
||||
editorStatus: "Состояние редактора: {{status}}",
|
||||
@@ -2249,7 +2250,8 @@ export const ru: TranslationResources = {
|
||||
automaticHint: "Paseo выбирает быструю доступную модель",
|
||||
preferredHint: "Выберите модель, которую использует Paseo",
|
||||
model: "Модель",
|
||||
fallbackHint: "Если она недоступна, Paseo использует другую доступную модель",
|
||||
modelHint:
|
||||
"Используется только эта модель. Если она не сработает, Paseo не будет пробовать другую модель",
|
||||
docs: "Документация",
|
||||
saveError: "Не удалось обновить настройки генерации метаданных",
|
||||
},
|
||||
|
||||
@@ -1906,7 +1906,6 @@ export const zhCN: TranslationResources = {
|
||||
alwaysUseExtension: "始终用于 .{{extension}}",
|
||||
alwaysUseFilename: "始终用于 {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "文件操作",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
@@ -1925,6 +1924,8 @@ export const zhCN: TranslationResources = {
|
||||
notFound: "找不到文件",
|
||||
failedToLoadPreview: "加载文件预览失败",
|
||||
editor: {
|
||||
preview: "预览",
|
||||
source: "源代码",
|
||||
fileSize: "文件大小 {{size}}",
|
||||
lines: "{{count}} 行",
|
||||
editorStatus: "编辑器状态 {{status}}",
|
||||
@@ -2193,7 +2194,7 @@ export const zhCN: TranslationResources = {
|
||||
automaticHint: "Paseo 会选择一个可用的快速模型",
|
||||
preferredHint: "选择 Paseo 使用的模型",
|
||||
model: "模型",
|
||||
fallbackHint: "如果不可用,Paseo 会改用其他可用模型",
|
||||
modelHint: "只使用此模型。如果失败,Paseo 不会尝试其他模型",
|
||||
docs: "文档",
|
||||
saveError: "无法更新元数据生成设置",
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@ const VALID_ROUTE = {
|
||||
serverId: "server-1",
|
||||
agentId: "agent-1",
|
||||
cachedWorkspaceId: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
hasHydratedLayout: true,
|
||||
} as const;
|
||||
|
||||
describe("resolveAgentRoute", () => {
|
||||
@@ -32,6 +34,20 @@ describe("resolveAgentRoute", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ hasHydratedWorkspaces: false, hasHydratedLayout: true },
|
||||
{ hasHydratedWorkspaces: true, hasHydratedLayout: false },
|
||||
])("waits for workspace and layout hydration: %j", (hydration) => {
|
||||
expect(
|
||||
resolveAgentRoute({
|
||||
...VALID_ROUTE,
|
||||
...hydration,
|
||||
connectionStatus: "online",
|
||||
lookup: { kind: "found", workspaceId: "workspace-2" },
|
||||
}),
|
||||
).toEqual({ kind: "fetchingAgent" });
|
||||
});
|
||||
|
||||
it("fetches the agent after its target host connects", () => {
|
||||
expect(
|
||||
resolveAgentRoute({
|
||||
|
||||
@@ -24,13 +24,18 @@ export function resolveAgentRoute(input: {
|
||||
cachedWorkspaceId: string | null | undefined;
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
lookup: AgentRouteLookup;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
hasHydratedLayout: boolean;
|
||||
}): AgentRouteResolution {
|
||||
if (!input.serverId || !input.agentId) {
|
||||
return { kind: "invalid" };
|
||||
}
|
||||
|
||||
const hydrationPending = !input.hasHydratedWorkspaces || !input.hasHydratedLayout;
|
||||
if (hydrationPending && input.connectionStatus === "online") return { kind: "fetchingAgent" };
|
||||
|
||||
const cachedWorkspaceId = normalizeWorkspaceOpaqueId(input.cachedWorkspaceId);
|
||||
if (cachedWorkspaceId) {
|
||||
if (cachedWorkspaceId && !hydrationPending) {
|
||||
return { kind: "resolved", workspaceId: cachedWorkspaceId };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createNotificationOpenRouter,
|
||||
parseNotificationOpenTarget,
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
} from "./model";
|
||||
export type { NotificationOpenSource, NotificationTap } from "./source";
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNotificationOpenRouter, parseNotificationOpenTarget } from "./model";
|
||||
import type { NotificationOpenSource, NotificationTap } from "./source";
|
||||
|
||||
class FakeSource implements NotificationOpenSource {
|
||||
listeners = new Set<(tap: NotificationTap) => void>();
|
||||
acknowledged: string[] = [];
|
||||
launchReads = 0;
|
||||
constructor(readonly launch: NotificationTap | null) {}
|
||||
subscribe(onTap: (tap: NotificationTap) => void) {
|
||||
this.listeners.add(onTap);
|
||||
return () => {
|
||||
this.listeners.delete(onTap);
|
||||
};
|
||||
}
|
||||
async takeLaunchTap() {
|
||||
this.launchReads++;
|
||||
return this.launch;
|
||||
}
|
||||
acknowledge(tap: NotificationTap) {
|
||||
this.acknowledged.push(tap.id);
|
||||
}
|
||||
tap(tap: NotificationTap) {
|
||||
for (const listener of this.listeners) listener(tap);
|
||||
}
|
||||
}
|
||||
const launch: NotificationTap = {
|
||||
id: "one",
|
||||
data: { serverId: "host", workspaceId: "stale", agentId: "agent" },
|
||||
};
|
||||
async function flush() {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("notification open", () => {
|
||||
it.each([
|
||||
[undefined, null],
|
||||
[null, null],
|
||||
[[], null],
|
||||
[{ agentId: "a" }, null],
|
||||
[{ serverId: " " }, null],
|
||||
[{ serverId: " h " }, { kind: "host", serverId: "h" }],
|
||||
[
|
||||
{ serverId: "h", agentId: "a" },
|
||||
{ kind: "agent", serverId: "h", agentId: "a" },
|
||||
],
|
||||
[
|
||||
{ serverId: "h", workspaceId: "stale", agentId: "a" },
|
||||
{ kind: "agent", serverId: "h", agentId: "a" },
|
||||
],
|
||||
[
|
||||
{ serverId: "h", workspaceId: "w", terminalId: "t", agentId: "a" },
|
||||
{ kind: "terminal", serverId: "h", workspaceId: "w", terminalId: "t" },
|
||||
],
|
||||
])("parses %j", (data, expected) => {
|
||||
expect(parseNotificationOpenTarget(data)).toEqual(expected);
|
||||
});
|
||||
|
||||
function setup(whenReadyToNavigate = () => Promise.resolve()) {
|
||||
const source = new FakeSource(launch);
|
||||
const agents: unknown[] = [];
|
||||
const routes: string[] = [];
|
||||
const terminals: unknown[] = [];
|
||||
const router = createNotificationOpenRouter({
|
||||
source,
|
||||
openAgent: (target) => {
|
||||
agents.push(target);
|
||||
},
|
||||
openTerminal: (target) => {
|
||||
terminals.push(target);
|
||||
},
|
||||
openRoute: (route) => {
|
||||
routes.push(route);
|
||||
},
|
||||
whenReadyToNavigate,
|
||||
});
|
||||
return { source, agents, routes, terminals, router };
|
||||
}
|
||||
|
||||
it("takes the launch tap once across remounts", async () => {
|
||||
const { source, agents, router } = setup();
|
||||
router.start()();
|
||||
const stop = router.start();
|
||||
await flush();
|
||||
expect(source.launchReads).toBe(1);
|
||||
expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("ignores listener replay of the launch tap after remount", async () => {
|
||||
const { source, agents, router } = setup();
|
||||
const stop = router.start();
|
||||
await flush();
|
||||
stop();
|
||||
const stopAgain = router.start();
|
||||
source.tap(launch);
|
||||
await flush();
|
||||
expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]);
|
||||
expect(source.acknowledged).toEqual(["one"]);
|
||||
stopAgain();
|
||||
});
|
||||
|
||||
it("waits for readiness before dispatch and acknowledgement", async () => {
|
||||
let ready = () => {};
|
||||
const readiness = new Promise<void>((resolve) => {
|
||||
ready = resolve;
|
||||
});
|
||||
const { source, agents, router } = setup(() => readiness);
|
||||
const stop = router.start();
|
||||
await flush();
|
||||
expect(agents).toEqual([]);
|
||||
expect(source.acknowledged).toEqual([]);
|
||||
ready();
|
||||
await flush();
|
||||
expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]);
|
||||
expect(source.acknowledged).toEqual(["one"]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("acknowledges each distinct tap including invalid targets", async () => {
|
||||
const { source, agents, router } = setup();
|
||||
const stop = router.start();
|
||||
await flush();
|
||||
source.tap({ id: "two", data: { serverId: "host", agentId: "second" } });
|
||||
source.tap({ id: "invalid", data: undefined });
|
||||
await flush();
|
||||
expect(agents).toEqual([
|
||||
{ serverId: "host", agentId: "agent" },
|
||||
{ serverId: "host", agentId: "second" },
|
||||
]);
|
||||
expect(source.acknowledged).toEqual(["one", "two", "invalid"]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("dispatches terminals and hosts through their navigation capabilities", async () => {
|
||||
const { source, routes, terminals, router } = setup();
|
||||
const stop = router.start();
|
||||
source.tap({
|
||||
id: "terminal",
|
||||
data: { serverId: "host", workspaceId: "ws", terminalId: "term" },
|
||||
});
|
||||
source.tap({ id: "host", data: { serverId: "host" } });
|
||||
await flush();
|
||||
expect(routes).toEqual(["/h/host"]);
|
||||
expect(terminals).toEqual([
|
||||
{ kind: "terminal", serverId: "host", workspaceId: "ws", terminalId: "term" },
|
||||
]);
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import type { NotificationOpenSource, NotificationTap } from "./source";
|
||||
|
||||
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
||||
|
||||
export type NotificationOpenTarget =
|
||||
| { kind: "agent"; serverId: string; agentId: string }
|
||||
| { kind: "terminal"; serverId: string; workspaceId: string; terminalId: string }
|
||||
| { kind: "host"; serverId: string };
|
||||
|
||||
function readId(data: object, key: string): string {
|
||||
const value: unknown = Reflect.get(data, key);
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function parseNotificationOpenTarget(data: unknown): NotificationOpenTarget | null {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
|
||||
const serverId = readId(data, "serverId");
|
||||
if (!serverId) return null;
|
||||
const workspaceId = readId(data, "workspaceId");
|
||||
const terminalId = readId(data, "terminalId");
|
||||
if (workspaceId && terminalId) return { kind: "terminal", serverId, workspaceId, terminalId };
|
||||
const agentId = readId(data, "agentId");
|
||||
if (agentId) return { kind: "agent", serverId, agentId };
|
||||
return { kind: "host", serverId };
|
||||
}
|
||||
|
||||
interface NotificationOpenRouterDeps {
|
||||
source: NotificationOpenSource;
|
||||
openAgent: (target: { serverId: string; agentId: string }) => void;
|
||||
openRoute: (route: string) => void;
|
||||
openTerminal: (target: Extract<NotificationOpenTarget, { kind: "terminal" }>) => void;
|
||||
whenReadyToNavigate: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createNotificationOpenRouter(deps: NotificationOpenRouterDeps) {
|
||||
const handledIds = new Set<string>();
|
||||
let launchTaken = false;
|
||||
async function receive(tap: NotificationTap) {
|
||||
if (handledIds.has(tap.id)) return;
|
||||
handledIds.add(tap.id);
|
||||
await deps.whenReadyToNavigate();
|
||||
const target = parseNotificationOpenTarget(tap.data);
|
||||
if (target?.kind === "agent") {
|
||||
deps.openAgent({ serverId: target.serverId, agentId: target.agentId });
|
||||
} else if (target?.kind === "terminal") {
|
||||
deps.openTerminal(target);
|
||||
} else if (target?.kind === "host") {
|
||||
deps.openRoute(buildHostRootRoute(target.serverId));
|
||||
}
|
||||
deps.source.acknowledge(tap);
|
||||
}
|
||||
return {
|
||||
start() {
|
||||
const unsubscribe = deps.source.subscribe((tap) => {
|
||||
void receive(tap);
|
||||
});
|
||||
if (!launchTaken) {
|
||||
launchTaken = true;
|
||||
void deps.source.takeLaunchTap().then((tap) => {
|
||||
return tap ? receive(tap) : undefined;
|
||||
});
|
||||
}
|
||||
return unsubscribe;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect } from "react";
|
||||
import { router, usePathname, useRootNavigationState, type Href } from "expo-router";
|
||||
import { useHostRegistryStatus } from "@/runtime/host-runtime";
|
||||
import {
|
||||
navigateToWorkspace,
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { createNotificationOpenRouter } from "../model";
|
||||
import { createNotificationOpenSource } from "../source";
|
||||
|
||||
let ready = false;
|
||||
const waiters = new Set<() => void>();
|
||||
const notificationRouter = createNotificationOpenRouter({
|
||||
source: createNotificationOpenSource(),
|
||||
openAgent: (target) => navigateToAgent({ ...target, pin: true }),
|
||||
openTerminal: ({ serverId, workspaceId, terminalId }) =>
|
||||
navigateToWorkspace({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
}),
|
||||
openRoute: (route) => router.navigate(route as Href),
|
||||
whenReadyToNavigate: () =>
|
||||
ready
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => {
|
||||
waiters.add(resolve);
|
||||
}),
|
||||
});
|
||||
|
||||
export function NotificationOpenRouter({ startupBlocked }: { startupBlocked: boolean }) {
|
||||
const root = useRootNavigationState();
|
||||
const pathname = usePathname();
|
||||
const registryStatus = useHostRegistryStatus();
|
||||
const selectionHydrated = useIsLastWorkspaceSelectionHydrated();
|
||||
// Index routes redirect during startup. Let their final leaf commit before dispatching a tap.
|
||||
const atStartupIndex = pathname === "/" || /^\/h\/[^/]+\/?$/.test(pathname);
|
||||
const canNavigate = Boolean(
|
||||
root?.key &&
|
||||
registryStatus === "ready" &&
|
||||
selectionHydrated &&
|
||||
!startupBlocked &&
|
||||
!atStartupIndex,
|
||||
);
|
||||
useEffect(() => {
|
||||
ready = canNavigate;
|
||||
if (ready) {
|
||||
for (const resolve of waiters) resolve();
|
||||
waiters.clear();
|
||||
}
|
||||
return () => {
|
||||
ready = false;
|
||||
};
|
||||
}, [canNavigate]);
|
||||
useEffect(() => notificationRouter.start(), []);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { createNotificationOpenSource as createWebSource } from "./source.web";
|
||||
import type { NotificationOpenSource } from "./source";
|
||||
|
||||
export function createNotificationOpenSource(): NotificationOpenSource {
|
||||
const web = createWebSource();
|
||||
let nextId = 0;
|
||||
return {
|
||||
...web,
|
||||
subscribe(onTap) {
|
||||
const stopWeb = web.subscribe(onTap);
|
||||
let disposed = false;
|
||||
let stopDesktop: (() => void) | undefined;
|
||||
const subscription = getDesktopHost()?.events?.on?.(
|
||||
"notification-click",
|
||||
(payload: unknown) => {
|
||||
const data =
|
||||
typeof payload === "object" && payload !== null && "data" in payload
|
||||
? payload.data
|
||||
: undefined;
|
||||
onTap({
|
||||
id: `electron:${++nextId}`,
|
||||
data:
|
||||
typeof data === "object" && data !== null
|
||||
? Object.fromEntries(Object.entries(data))
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
void Promise.resolve(subscription).then((stop) => {
|
||||
if (disposed) stop?.();
|
||||
else stopDesktop = stop;
|
||||
return;
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
stopWeb();
|
||||
stopDesktop?.();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as Notifications from "expo-notifications";
|
||||
import type { NotificationOpenSource, NotificationTap } from "./source";
|
||||
|
||||
function toTap(response: Notifications.NotificationResponse): NotificationTap {
|
||||
return {
|
||||
id: response.notification.request.identifier,
|
||||
data: response.notification.request.content.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function createNotificationOpenSource(): NotificationOpenSource {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: false,
|
||||
shouldShowBanner: false,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
return {
|
||||
subscribe(onTap) {
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener((response) =>
|
||||
onTap(toTap(response)),
|
||||
);
|
||||
return () => subscription.remove();
|
||||
},
|
||||
async takeLaunchTap() {
|
||||
const response = await Notifications.getLastNotificationResponseAsync();
|
||||
return response ? toTap(response) : null;
|
||||
},
|
||||
acknowledge() {
|
||||
Notifications.clearLastNotificationResponse();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface NotificationTap {
|
||||
id: string;
|
||||
data: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export interface NotificationOpenSource {
|
||||
subscribe(onTap: (tap: NotificationTap) => void): () => void;
|
||||
takeLaunchTap(): Promise<NotificationTap | null>;
|
||||
acknowledge(tap: NotificationTap): void;
|
||||
}
|
||||
|
||||
export { createNotificationOpenSource } from "./source.native";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { WEB_NOTIFICATION_CLICK_EVENT } from "./model";
|
||||
import type { NotificationOpenSource } from "./source";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export function createNotificationOpenSource(): NotificationOpenSource {
|
||||
let nextId = 0;
|
||||
return {
|
||||
subscribe(onTap) {
|
||||
if (!isWeb) return () => {};
|
||||
function receive(event: Event) {
|
||||
if (!(event instanceof CustomEvent)) return;
|
||||
event.preventDefault();
|
||||
onTap({ id: `web:${++nextId}`, data: event.detail?.data });
|
||||
}
|
||||
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, receive);
|
||||
return () => window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, receive);
|
||||
},
|
||||
async takeLaunchTap() {
|
||||
return null;
|
||||
},
|
||||
acknowledge() {},
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,13 @@ import type {
|
||||
} from "./validation";
|
||||
import type { PluginButtonIcon, PluginHostProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useSyncExternalStore,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { AlertCircle, ChevronDown, MoreHorizontal } from "lucide-react-native";
|
||||
@@ -180,7 +186,7 @@ function ButtonBody({
|
||||
behavior={item.behavior}
|
||||
disabled={view.entry.pending || view.entry.button.disabled || item.disabled === true}
|
||||
path={itemPath}
|
||||
pageId={buttonPageId(view, itemPath)}
|
||||
pageId={buttonPageId(view.entry.key, itemPath)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -204,14 +210,19 @@ function ButtonSurfaceBody({
|
||||
);
|
||||
}
|
||||
|
||||
function buttonPageId(view: ButtonView, path: readonly string[]): string {
|
||||
return [view.entry.key, ...path].join("/");
|
||||
// Prefixed so plugin pages can share a surface with the host's own pages.
|
||||
function buttonPageId(key: number, path: readonly string[]): string {
|
||||
return ["plugin-button", key, ...path].join("/");
|
||||
}
|
||||
|
||||
type RenderButtonPage = (behavior: PluginButtonBehavior, path: readonly string[]) => ReactNode;
|
||||
|
||||
/** A page for every menu and popover item below `behavior`, depth first. */
|
||||
function buttonPages(
|
||||
view: ButtonView,
|
||||
key: number,
|
||||
behavior: PluginButtonBehavior,
|
||||
path: readonly string[] = [],
|
||||
path: readonly string[],
|
||||
render: RenderButtonPage,
|
||||
): MenuPageDefinition[] {
|
||||
if (behavior.kind !== "menu") return [];
|
||||
return behavior.items.flatMap((item) => {
|
||||
@@ -220,12 +231,12 @@ function buttonPages(
|
||||
const itemPath = [...path, item.id];
|
||||
return [
|
||||
{
|
||||
id: buttonPageId(view, itemPath),
|
||||
id: buttonPageId(key, itemPath),
|
||||
title: item.title,
|
||||
hoverIntent: item.behavior.kind === "menu",
|
||||
content: <ButtonSurfaceBody view={view} behavior={item.behavior} path={itemPath} />,
|
||||
content: render(item.behavior, itemPath),
|
||||
},
|
||||
...buttonPages(view, item.behavior, itemPath),
|
||||
...buttonPages(key, item.behavior, itemPath, render),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -304,7 +315,13 @@ function ButtonControl({ view }: { view: ButtonView }) {
|
||||
{contents}
|
||||
</Pressable>
|
||||
);
|
||||
const pages = useMemo(() => buttonPages(view, button.behavior), [view, button.behavior]);
|
||||
const pages = useMemo(
|
||||
() =>
|
||||
buttonPages(entry.key, button.behavior, ROOT_PATH, (behavior, path) => (
|
||||
<ButtonSurfaceBody view={view} behavior={behavior} path={path} />
|
||||
)),
|
||||
[view, entry.key, button.behavior],
|
||||
);
|
||||
return (
|
||||
<MenuRoot compactMode="sheet" open={entry.open} onOpenChange={setOpen}>
|
||||
<Tooltip enabledOnMobile={false}>
|
||||
@@ -424,82 +441,112 @@ function PluginButtonHost({
|
||||
|
||||
const ThemedPluginButton = withUnistyles(PluginButtonHost);
|
||||
|
||||
// Overflow rows keep their own plugin runtime; a menu can contain several installations.
|
||||
function OverflowButton({ view }: { view: ButtonView }) {
|
||||
const { button } = view.entry;
|
||||
return (
|
||||
<ButtonMenuItem
|
||||
view={view}
|
||||
title={button.title}
|
||||
icon={button.icon}
|
||||
behavior={button.behavior}
|
||||
disabled={button.disabled || view.entry.pending}
|
||||
path={ROOT_PATH}
|
||||
pageId={buttonPageId(view, [])}
|
||||
/>
|
||||
/**
|
||||
* Each row and page of a shared menu resolves its own installation's runtime: one menu can hold
|
||||
* buttons from several plugins, and none of them may run through another's host client.
|
||||
*/
|
||||
function useMenuButtonView(entry: RegisteredPluginButton, theme: PluginTheme): ButtonView | null {
|
||||
const client = useHostRuntimeClient(entry.installation.serverId);
|
||||
const toast = useToast();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const hosts = useHosts();
|
||||
const serverId = entry.installation.serverId;
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
return useMemo(
|
||||
() => createButtonView({ entry, client, toast, compact, hostLabel, theme }),
|
||||
[entry, client, toast, compact, hostLabel, theme],
|
||||
);
|
||||
}
|
||||
|
||||
function OverflowPages({
|
||||
entries,
|
||||
compact,
|
||||
hostLabel,
|
||||
function MenuButtonRow({ entry, theme }: { entry: RegisteredPluginButton; theme: PluginTheme }) {
|
||||
const view = useMenuButtonView(entry, theme);
|
||||
if (!view) return null;
|
||||
const { button } = entry;
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={entry.installation} Surface={button.icon}>
|
||||
<ButtonEnvironment view={view}>
|
||||
<ButtonMenuItem
|
||||
view={view}
|
||||
title={button.title}
|
||||
icon={button.icon}
|
||||
behavior={button.behavior}
|
||||
disabled={button.disabled || entry.pending}
|
||||
path={ROOT_PATH}
|
||||
pageId={buttonPageId(entry.key, ROOT_PATH)}
|
||||
/>
|
||||
</ButtonEnvironment>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuButtonPage({
|
||||
entry,
|
||||
behavior,
|
||||
path,
|
||||
theme,
|
||||
}: {
|
||||
entries: readonly RegisteredPluginButton[];
|
||||
compact: boolean;
|
||||
hostLabel: string;
|
||||
entry: RegisteredPluginButton;
|
||||
behavior: PluginButtonBehavior;
|
||||
path: readonly string[];
|
||||
theme: PluginTheme;
|
||||
}) {
|
||||
// The header belongs to one host, but each button keeps its installation's query cache and RPCs.
|
||||
const client = useHostRuntimeClient(entries[0].installation.serverId);
|
||||
const toast = useToast();
|
||||
const menuContent = useMemo(() => {
|
||||
const pages: MenuPageDefinition[] = [];
|
||||
const rows: ReactNode[] = [];
|
||||
for (const entry of entries) {
|
||||
const view = createButtonView({ entry, client, toast, compact, hostLabel, theme });
|
||||
if (!view) continue;
|
||||
rows.push(
|
||||
<SurfaceErrorBoundary
|
||||
key={entry.key}
|
||||
installation={entry.installation}
|
||||
Surface={entry.button.icon}
|
||||
>
|
||||
<ButtonEnvironment view={view}>
|
||||
<OverflowButton view={view} />
|
||||
</ButtonEnvironment>
|
||||
</SurfaceErrorBoundary>,
|
||||
);
|
||||
if (entry.button.behavior.kind === "action") continue;
|
||||
pages.push(
|
||||
{
|
||||
id: buttonPageId(view, []),
|
||||
title: entry.button.title,
|
||||
hoverIntent: false,
|
||||
content: (
|
||||
<ButtonSurfaceBody view={view} behavior={entry.button.behavior} path={ROOT_PATH} />
|
||||
),
|
||||
},
|
||||
...buttonPages(view, entry.button.behavior),
|
||||
);
|
||||
}
|
||||
return { pages, rows };
|
||||
}, [entries, client, toast, theme, hostLabel, compact]);
|
||||
const view = useMenuButtonView(entry, theme);
|
||||
if (!view) return null;
|
||||
return <ButtonSurfaceBody view={view} behavior={behavior} path={path} />;
|
||||
}
|
||||
|
||||
const ThemedMenuButtonRow = withUnistyles(MenuButtonRow);
|
||||
const ThemedMenuButtonPage = withUnistyles(MenuButtonPage);
|
||||
|
||||
export interface PluginButtonsMenu {
|
||||
/** One row per button, in registration order. Render them on the menu's root page. */
|
||||
rows: ReactElement[];
|
||||
/** The pages those rows lead to. Pass them to the menu's surface. */
|
||||
pages: MenuPageDefinition[];
|
||||
}
|
||||
|
||||
function buttonsMenu(entries: readonly RegisteredPluginButton[]): PluginButtonsMenu {
|
||||
const rows = entries.map((entry) => (
|
||||
<ThemedMenuButtonRow key={entry.key} entry={entry} uniProps={pluginThemeMapping} />
|
||||
));
|
||||
const pages = entries.flatMap((entry): MenuPageDefinition[] => {
|
||||
const { behavior } = entry.button;
|
||||
if (behavior.kind === "action") return [];
|
||||
const render: RenderButtonPage = (pageBehavior, path) => (
|
||||
<ThemedMenuButtonPage
|
||||
entry={entry}
|
||||
behavior={pageBehavior}
|
||||
path={path}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
);
|
||||
return [
|
||||
{
|
||||
id: buttonPageId(entry.key, ROOT_PATH),
|
||||
title: entry.button.title,
|
||||
hoverIntent: false,
|
||||
content: render(behavior, ROOT_PATH),
|
||||
},
|
||||
...buttonPages(entry.key, behavior, ROOT_PATH, render),
|
||||
];
|
||||
});
|
||||
return { rows, pages };
|
||||
}
|
||||
|
||||
function OverflowSurface({ entries }: { entries: readonly RegisteredPluginButton[] }) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMemo(() => buttonsMenu(entries), [entries]);
|
||||
return (
|
||||
<PluginPopoverSurface
|
||||
sheetTitle={t("workspace.git.actions.moreActions")}
|
||||
align="end"
|
||||
pages={menuContent.pages}
|
||||
pages={menu.pages}
|
||||
>
|
||||
{menuContent.rows}
|
||||
{menu.rows}
|
||||
</PluginPopoverSurface>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedOverflowPages = withUnistyles(OverflowPages);
|
||||
|
||||
function useButtons(serverId: string, workspaceId: string, agentId: string | null) {
|
||||
const entries = useSyncExternalStore(
|
||||
pluginButtonStore.subscribe,
|
||||
@@ -545,6 +592,15 @@ export function PluginComposerPills({
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace's header buttons as rows and pages of a host menu. Compact headers have no room for
|
||||
* the buttons themselves, so the workspace menu carries them instead.
|
||||
*/
|
||||
export function usePluginWorkspaceMenu(serverId: string, workspaceId: string): PluginButtonsMenu {
|
||||
const entries = useButtons(serverId, workspaceId, null);
|
||||
return useMemo(() => buttonsMenu(entries), [entries]);
|
||||
}
|
||||
|
||||
export function PluginHeaderButtons({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -590,12 +646,7 @@ export function PluginHeaderButtons({
|
||||
>
|
||||
<ThemedMoreIcon size={16} uniProps={mutedIconMapping} />
|
||||
</MenuTrigger>
|
||||
<ThemedOverflowPages
|
||||
entries={overflow}
|
||||
compact={compact}
|
||||
hostLabel={hostLabel}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
<OverflowSurface entries={overflow} />
|
||||
</MenuRoot>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -7,4 +7,5 @@ export {
|
||||
PluginComposerPills,
|
||||
PluginHeaderButtons,
|
||||
useHasPluginComposerPills,
|
||||
usePluginWorkspaceMenu,
|
||||
} from "./buttons/view";
|
||||
|
||||
@@ -1011,6 +1011,50 @@ describe("HostRuntimeController", () => {
|
||||
expect(activeClient.latencyMeasurements()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([12, null])(
|
||||
"falls back to relay when the direct client disconnects with heartbeat RTT %s",
|
||||
async (heartbeatRtt) => {
|
||||
useHostRuntimeClock();
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
"direct:lan:6767": 12,
|
||||
"relay:relay.paseo.sh:443": 65,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host: makeHost(),
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("online");
|
||||
|
||||
const directClient = clients[0];
|
||||
directClient.heartbeatReportsRtt(heartbeatRtt);
|
||||
directClient.clearLatencyMeasurements();
|
||||
latencies["direct:lan:6767"] = new Error("LAN unreachable");
|
||||
directClient.setConnectionState({ status: "disconnected", reason: "network changed" });
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("error");
|
||||
expect(directClient.getLastLivenessRttMs()).toBe(heartbeatRtt);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await controller.runProbeCycleNow();
|
||||
|
||||
const snapshot = controller.getSnapshot();
|
||||
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
expect(snapshot.connectionStatus).toBe("online");
|
||||
expect(snapshot.probeByConnectionId).toEqual(
|
||||
new Map([
|
||||
["direct:lan:6767", { status: "unavailable", latencyMs: null }],
|
||||
["relay:relay.paseo.sh:443", { status: "available", latencyMs: 65 }],
|
||||
]),
|
||||
);
|
||||
expect(directClient.isDisposed()).toBe(true);
|
||||
expect(directClient.latencyMeasurements()).toEqual([]);
|
||||
await controller.stop();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not create a probe client for the selected connection while it reconnects", async () => {
|
||||
useHostRuntimeClock();
|
||||
const relay: HostConnection = {
|
||||
|
||||
@@ -1110,6 +1110,14 @@ export class HostRuntimeController {
|
||||
shouldCloseClient = shouldCloseClient && !activated;
|
||||
|
||||
if (activeClient) {
|
||||
if (activeClient.getConnectionState().status !== "connected") {
|
||||
probeByConnectionId.set(connection.id, {
|
||||
status: "unavailable",
|
||||
latencyMs: null,
|
||||
});
|
||||
publishProbeState();
|
||||
return;
|
||||
}
|
||||
const rttMs = activeClient.getLastLivenessRttMs();
|
||||
if (!this.isCurrentProbeRequest(requestVersion)) {
|
||||
return;
|
||||
|
||||
@@ -139,7 +139,7 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) {
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.metadataGeneration.model")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.metadataGeneration.fallbackHint")}
|
||||
{t("settings.metadataGeneration.modelHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<CombinedModelSelector
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
useWorkspaceScriptsMenuPages,
|
||||
type UseWorkspaceScriptsMenuPagesInput,
|
||||
} from "@/screens/workspace/scripts";
|
||||
import { usePluginWorkspaceMenu } from "@/plugins";
|
||||
|
||||
const ThemedEllipsis = withUnistyles(Ellipsis);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
@@ -206,6 +207,7 @@ function HeaderMenuProfileItem({
|
||||
|
||||
export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspaceActions {
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
scripts: UseWorkspaceScriptsMenuPagesInput;
|
||||
showCreateBrowserTab: boolean;
|
||||
createTerminalDisabled: boolean;
|
||||
@@ -218,10 +220,12 @@ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspace
|
||||
* the rarer launches stay here alongside the workspace actions.
|
||||
*
|
||||
* The header has no room for a scripts button either, so scripts are a page of this menu, and the
|
||||
* ⋯ carries a dot while a running service is unhealthy.
|
||||
* ⋯ carries a dot while a running service is unhealthy. Plugin header buttons are rows here for the
|
||||
* same reason.
|
||||
*/
|
||||
export function WorkspaceHeaderMenuMobile({
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
scripts,
|
||||
showCreateBrowserTab,
|
||||
createTerminalDisabled,
|
||||
@@ -242,6 +246,11 @@ export function WorkspaceHeaderMenuMobile({
|
||||
router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href);
|
||||
}, [normalizedServerId, router]);
|
||||
const scriptsMenu = useWorkspaceScriptsMenuPages(scripts);
|
||||
const pluginMenu = usePluginWorkspaceMenu(normalizedServerId, normalizedWorkspaceId);
|
||||
const pages = useMemo(
|
||||
() => [...scriptsMenu.pages, ...pluginMenu.pages],
|
||||
[scriptsMenu.pages, pluginMenu.pages],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
@@ -266,10 +275,12 @@ export function WorkspaceHeaderMenuMobile({
|
||||
align="start"
|
||||
width={220}
|
||||
testID="workspace-header-menu"
|
||||
pages={scriptsMenu.pages}
|
||||
pages={pages}
|
||||
sheetTitle={t("workspace.header.actions.workspaceActions")}
|
||||
>
|
||||
{scriptsMenu.trigger}
|
||||
{pluginMenu.rows}
|
||||
{pluginMenu.rows.length > 0 ? <DropdownMenuSeparator /> : null}
|
||||
{showCreateBrowserTab ? (
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-new-browser"
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
type SheetTab,
|
||||
type WorkspaceTabPlacement,
|
||||
useWorkspaceLayoutStore,
|
||||
usePendingWorkspaceOpen,
|
||||
useWorkspaceLayoutStoreHydrated,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
@@ -271,12 +272,10 @@ interface WorkspaceScreenProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
isRouteFocused?: boolean;
|
||||
recoveryRequested?: boolean;
|
||||
}
|
||||
|
||||
type WorkspaceScreenContentProps = WorkspaceScreenProps & {
|
||||
isRouteFocused: boolean;
|
||||
recoveryRequested: boolean;
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
@@ -419,7 +418,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
serverId,
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
}: WorkspaceScreenProps) {
|
||||
const navigationFocused = useIsFocused();
|
||||
useEffect(() => {
|
||||
@@ -433,7 +431,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isRouteFocused={isRouteFocused ?? navigationFocused}
|
||||
recoveryRequested={recoveryRequested ?? false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -748,9 +745,9 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
function shouldInspectWorkspaceRecovery(
|
||||
hasHydratedWorkspaces: boolean,
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
recoveryRequested: boolean,
|
||||
pendingOpenTarget: WorkspaceTabTarget | null,
|
||||
): boolean {
|
||||
return recoveryRequested && hasHydratedWorkspaces && workspace === null;
|
||||
return pendingOpenTarget?.kind === "agent" && hasHydratedWorkspaces && workspace === null;
|
||||
}
|
||||
|
||||
function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
|
||||
@@ -1015,7 +1012,6 @@ function WorkspaceScreenContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
}: WorkspaceScreenContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const _insets = useSafeAreaInsets();
|
||||
@@ -1149,13 +1145,18 @@ function WorkspaceScreenContent({
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
);
|
||||
const pendingOpenTarget = usePendingWorkspaceOpen({
|
||||
workspaceKey: persistenceKey,
|
||||
hasHydratedWorkspaces,
|
||||
workspaceExists: workspaceDescriptor !== null,
|
||||
});
|
||||
const workspaceRecovery = useWorkspaceRecovery({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
enabled: shouldInspectWorkspaceRecovery(
|
||||
hasHydratedWorkspaces,
|
||||
workspaceDescriptor,
|
||||
recoveryRequested,
|
||||
pendingOpenTarget,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -3405,6 +3406,7 @@ function WorkspaceScreenContent({
|
||||
/>
|
||||
<WorkspaceHeaderMenuMobile
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
scripts={compactHeaderScripts}
|
||||
currentBranchName={currentBranchName}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
|
||||
@@ -38,6 +38,7 @@ function navigateDeps(): NavigateToWorkspaceDeps {
|
||||
getSessionAgents: (serverId) =>
|
||||
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
|
||||
isWorkspaceLayoutHydrated: () => useWorkspaceLayoutStore.persist.hasHydrated(),
|
||||
deferOpenTab: (input) => useWorkspaceLayoutStore.getState().deferOpenTab(input),
|
||||
openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input),
|
||||
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
|
||||
navigateToRoute: (route) => {
|
||||
|
||||
@@ -21,7 +21,12 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
|
||||
const navigations: string[] = [];
|
||||
const remembered: ActiveWorkspaceSelection[] = [];
|
||||
const openedTabs: RecordedTab[] = [];
|
||||
const deferredTabs: unknown[] = [];
|
||||
const deps: NavigateToWorkspaceDeps = {
|
||||
deferOpenTab: (input) => {
|
||||
deferredTabs.push(input);
|
||||
return null;
|
||||
},
|
||||
getSessionWorkspaces: () => null,
|
||||
getSessionAgents: () => [] as Agent[],
|
||||
isWorkspaceLayoutHydrated: () => true,
|
||||
@@ -33,7 +38,7 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
|
||||
navigateToRoute: (route) => navigations.push(route),
|
||||
...overrides,
|
||||
};
|
||||
return { deps, navigations, remembered, openedTabs };
|
||||
return { deps, navigations, remembered, openedTabs, deferredTabs };
|
||||
}
|
||||
|
||||
function createLastSelectionDeps(
|
||||
@@ -139,7 +144,7 @@ describe("workspace navigation", () => {
|
||||
});
|
||||
|
||||
it("defers an agent tab until a missing workspace is recovered", () => {
|
||||
const { deps, navigations, openedTabs } = createFakeDeps({
|
||||
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
|
||||
getSessionWorkspaces: () => new Map(),
|
||||
});
|
||||
|
||||
@@ -153,7 +158,8 @@ describe("workspace navigation", () => {
|
||||
);
|
||||
|
||||
expect(openedTabs).toEqual([]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
|
||||
expect(deferredTabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("defers an agent tab until persisted workspace layout has hydrated", () => {
|
||||
@@ -161,7 +167,7 @@ describe("workspace navigation", () => {
|
||||
id: "workspace-a",
|
||||
workspaceDirectory: "/repo/workspace-a",
|
||||
} as WorkspaceDescriptor;
|
||||
const { deps, navigations, openedTabs } = createFakeDeps({
|
||||
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
|
||||
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
|
||||
isWorkspaceLayoutHydrated: () => false,
|
||||
});
|
||||
@@ -177,7 +183,8 @@ describe("workspace navigation", () => {
|
||||
);
|
||||
|
||||
expect(openedTabs).toEqual([]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
|
||||
expect(deferredTabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reads the active workspace from the current route", () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { pickAttentionAgent } from "@/utils/agent-attention";
|
||||
import {
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
|
||||
import type { OpenWorkspaceTabInput } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions";
|
||||
|
||||
export interface RouteSelectionInput {
|
||||
@@ -35,6 +35,7 @@ export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
|
||||
getSessionWorkspaces: (serverId: string) => Map<string, WorkspaceDescriptor> | null | undefined;
|
||||
getSessionAgents: (serverId: string) => Iterable<Agent>;
|
||||
isWorkspaceLayoutHydrated: () => boolean;
|
||||
deferOpenTab: (input: OpenWorkspaceTabInput) => null;
|
||||
rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void;
|
||||
navigateToRoute: (route: string) => void;
|
||||
}
|
||||
@@ -91,13 +92,10 @@ export function navigateToWorkspace(
|
||||
workspaces,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const shouldDeferAgentOpen = Boolean(
|
||||
input.target?.kind === "agent" && (!resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()),
|
||||
);
|
||||
const shouldDeferOpen = !resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated();
|
||||
if (input.target) {
|
||||
if (!shouldDeferAgentOpen) {
|
||||
prepareWorkspaceTab({ ...input, target: input.target }, deps);
|
||||
}
|
||||
const openTab = shouldDeferOpen ? deps.deferOpenTab : deps.openTab;
|
||||
prepareWorkspaceTab({ ...input, target: input.target }, { openTab });
|
||||
} else {
|
||||
const workspaceAgents = resolvedWorkspaceId
|
||||
? Array.from(deps.getSessionAgents(input.serverId)).filter(
|
||||
@@ -114,14 +112,7 @@ export function navigateToWorkspace(
|
||||
}
|
||||
}
|
||||
|
||||
const route =
|
||||
input.target?.kind === "agent" && shouldDeferAgentOpen
|
||||
? buildHostWorkspaceOpenRoute(
|
||||
input.serverId,
|
||||
input.workspaceId,
|
||||
`agent:${input.target.agentId}`,
|
||||
)
|
||||
: buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId });
|
||||
deps.navigateToRoute(route);
|
||||
return route;
|
||||
|
||||
@@ -1192,7 +1192,10 @@ export function restoreWorkspaceLayout(
|
||||
const normalized = normalizeLayout(layout);
|
||||
let root = asInternalNode(normalized.root);
|
||||
let focusedPaneId = normalized.focusedPaneId;
|
||||
if (!collectAllPanes(root).some((pane) => pane.id !== explorerSidebarPaneId)) {
|
||||
const visibleOrdinaryPane = collectAllPanes(root).find(
|
||||
(pane) => pane.id !== explorerSidebarPaneId,
|
||||
);
|
||||
if (!visibleOrdinaryPane) {
|
||||
const ordinaryPane = findOrdinaryPane(root, explorerSidebarPaneId);
|
||||
if (ordinaryPane) {
|
||||
root = updatePaneInTree(root, {
|
||||
@@ -1214,6 +1217,9 @@ export function restoreWorkspaceLayout(
|
||||
sizes: [0.78, 0.22],
|
||||
});
|
||||
}
|
||||
} else if (focusedPaneId === null) {
|
||||
// Parked focus has an in-memory restoration record that cannot survive reload.
|
||||
focusedPaneId = visibleOrdinaryPane.id;
|
||||
}
|
||||
return {
|
||||
...normalized,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
|
||||
import { expect, it } from "vitest";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import { createWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
import {
|
||||
createWorkspaceLayoutStore,
|
||||
collectAllTabs,
|
||||
selectExplorerSidebarPaneId,
|
||||
} from "./workspace-layout-store";
|
||||
import { deriveWorkspacePaneState } from "../screens/workspace/workspace-pane-state";
|
||||
import { selectVisibleAgentIds } from "../screens/workspace/visible-agent-ids";
|
||||
|
||||
Object.assign(globalThis, { indexedDB, IDBKeyRange });
|
||||
|
||||
@@ -88,3 +94,60 @@ it("keeps the saved layout when the shell publishes its layout mode before hydra
|
||||
saved.getState().layoutByWorkspace[workspaceKey],
|
||||
);
|
||||
});
|
||||
|
||||
it.each([1, 2])(
|
||||
"restores the agent pane after parked focus in storage version %i",
|
||||
async (version) => {
|
||||
const values = new Map<string, string>();
|
||||
const storage: StateStorage = {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key) => {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
const key = "host:workspace";
|
||||
const source = createWorkspaceLayoutStore(undefined, storage);
|
||||
await source.persist.rehydrate();
|
||||
source.getState().setLayoutMode("compact");
|
||||
source.getState().openTab({
|
||||
workspaceKey: key,
|
||||
target: { kind: "agent", agentId: "agent-a" },
|
||||
intent: "reveal",
|
||||
});
|
||||
source.getState().unfocusPane(key);
|
||||
expect(source.getState().layoutByWorkspace[key].focusedPaneId).toBeNull();
|
||||
const persisted = JSON.parse(values.get("workspace-layout-state")!);
|
||||
persisted.version = version;
|
||||
values.set("workspace-layout-state", JSON.stringify(persisted));
|
||||
const restored = createWorkspaceLayoutStore(undefined, storage);
|
||||
restored.getState().setLayoutMode("compact");
|
||||
await restored.persist.rehydrate();
|
||||
restored.getState().reconcileTabs(key, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["agent-a"],
|
||||
autoOpenAgentIds: ["agent-a"],
|
||||
knownTerminalIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
});
|
||||
const layout = restored.getState().layoutByWorkspace[key];
|
||||
const tabs = collectAllTabs(layout.root);
|
||||
const restoredExplorerId = selectExplorerSidebarPaneId(restored.getState(), key);
|
||||
const main = deriveWorkspacePaneState({ layout, tabs });
|
||||
const visible = selectVisibleAgentIds({
|
||||
layout,
|
||||
tabs,
|
||||
routeFocused: true,
|
||||
focusedPaneOnly: true,
|
||||
explorerPaneId: restoredExplorerId,
|
||||
explorerVisible: false,
|
||||
});
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
expect(main.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-a" });
|
||||
expect(main.tabs.map((t) => t.descriptor.kind)).toEqual(["agent"]);
|
||||
expect(visible).toEqual(["agent-a"]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5131,3 +5131,43 @@ describe("compact workspace sheet", () => {
|
||||
expect(received).toEqual([[], ["child"], []]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending workspace opens", () => {
|
||||
it("retains an open across hydration and consumes it once only for its workspace", async () => {
|
||||
const values = new Map<string, string>();
|
||||
const store = createWorkspaceLayoutStore(undefined, {
|
||||
getItem: async (key) => values.get(key) ?? null,
|
||||
setItem: async (key, value) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
values.delete(key);
|
||||
},
|
||||
});
|
||||
store.getState().deferOpenTab({
|
||||
workspaceKey: "server:beta",
|
||||
target: { kind: "agent", agentId: "b" },
|
||||
intent: "reveal",
|
||||
pin: true,
|
||||
});
|
||||
await store.persist.rehydrate();
|
||||
expect(store.getState().consumePendingOpen("server:alpha")).toBeNull();
|
||||
expect(
|
||||
store
|
||||
.getState()
|
||||
.getWorkspaceTabs("server:alpha")
|
||||
.filter((tab) => tab.target.kind === "agent"),
|
||||
).toEqual([]);
|
||||
expect(store.getState().consumePendingOpen("server:beta")).toBe("agent_b");
|
||||
expect(store.getState().consumePendingOpen("server:beta")).toBeNull();
|
||||
expect(
|
||||
store
|
||||
.getState()
|
||||
.getWorkspaceTabs("server:beta")
|
||||
.filter((tab) => tab.target.kind === "agent")
|
||||
.map((tab) => tab.target),
|
||||
).toEqual([{ kind: "agent", agentId: "b" }]);
|
||||
expect(store.getState().pinnedAgentIdsByWorkspace["server:beta"]).toEqual(new Set(["b"]));
|
||||
expect(store.getState().pendingOpenByWorkspace).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { persist, type StateStorage } from "zustand/middleware";
|
||||
import type { z } from "zod";
|
||||
@@ -130,6 +130,9 @@ export type WorkspaceTabDestination =
|
||||
interface WorkspaceLayoutStore {
|
||||
/** Published once by the app shell; callers never pass a form factor to open a tab. */
|
||||
layoutMode: WorkspaceLayoutMode;
|
||||
pendingOpenByWorkspace: Record<string, OpenWorkspaceTabInput>;
|
||||
deferOpenTab: (input: OpenWorkspaceTabInput) => null;
|
||||
consumePendingOpen: (workspaceKey: string) => string | null;
|
||||
/**
|
||||
* Each workspace's sheet tabs in the order they opened, so the last is the sheet on top. The
|
||||
* list never reorders: the stack belongs to the sheet primitive, and moving a sheet would remount
|
||||
@@ -1020,6 +1023,26 @@ export function createWorkspaceLayoutStore(
|
||||
}
|
||||
return tabId;
|
||||
},
|
||||
pendingOpenByWorkspace: {},
|
||||
deferOpenTab: (input) => {
|
||||
set((state) => ({
|
||||
pendingOpenByWorkspace: {
|
||||
...state.pendingOpenByWorkspace,
|
||||
[input.workspaceKey]: input,
|
||||
},
|
||||
}));
|
||||
return null;
|
||||
},
|
||||
consumePendingOpen: (workspaceKey) => {
|
||||
const pending = get().pendingOpenByWorkspace[workspaceKey];
|
||||
if (!pending) return null;
|
||||
set((state) => {
|
||||
const { [workspaceKey]: _pending, ...pendingOpenByWorkspace } =
|
||||
state.pendingOpenByWorkspace;
|
||||
return { pendingOpenByWorkspace };
|
||||
});
|
||||
return get().openTab(pending);
|
||||
},
|
||||
layoutMode: "split-panes",
|
||||
sheetsByWorkspace: {},
|
||||
layoutByWorkspace: {},
|
||||
@@ -2160,6 +2183,7 @@ export function createWorkspaceLayoutStore(
|
||||
|
||||
set((state) => {
|
||||
const hasAny =
|
||||
normalizedWorkspaceKey in state.pendingOpenByWorkspace ||
|
||||
normalizedWorkspaceKey in state.layoutByWorkspace ||
|
||||
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
|
||||
normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace ||
|
||||
@@ -2173,6 +2197,8 @@ export function createWorkspaceLayoutStore(
|
||||
if (!hasAny) {
|
||||
return state;
|
||||
}
|
||||
const { [normalizedWorkspaceKey]: _pendingOpen, ...pendingOpenByWorkspace } =
|
||||
state.pendingOpenByWorkspace;
|
||||
const {
|
||||
[normalizedWorkspaceKey]: _autoOpened,
|
||||
...pullRequestTabAutoOpenedByWorkspace
|
||||
@@ -2199,6 +2225,7 @@ export function createWorkspaceLayoutStore(
|
||||
state.sidePaneIdByWorkspace;
|
||||
return {
|
||||
...withSheets(state, normalizedWorkspaceKey, NO_SHEETS),
|
||||
pendingOpenByWorkspace,
|
||||
pullRequestTabAutoOpenedByWorkspace,
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace,
|
||||
@@ -2348,3 +2375,27 @@ export function useWorkspaceLayoutStoreHydrated(): boolean {
|
||||
|
||||
return hasHydrated;
|
||||
}
|
||||
|
||||
interface PendingWorkspaceOpenReadiness {
|
||||
workspaceKey: string | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
workspaceExists: boolean;
|
||||
}
|
||||
|
||||
export function usePendingWorkspaceOpen({
|
||||
workspaceKey,
|
||||
hasHydratedWorkspaces,
|
||||
workspaceExists,
|
||||
}: PendingWorkspaceOpenReadiness): WorkspaceTabTarget | null {
|
||||
const workspaceReady = hasHydratedWorkspaces && workspaceExists;
|
||||
const pending = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.pendingOpenByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
const layoutHydrated = useWorkspaceLayoutStoreHydrated();
|
||||
useLayoutEffect(() => {
|
||||
if (workspaceKey && workspaceReady && layoutHydrated && pending) {
|
||||
useWorkspaceLayoutStore.getState().consumePendingOpen(workspaceKey);
|
||||
}
|
||||
}, [workspaceKey, workspaceReady, layoutHydrated, pending]);
|
||||
return pending ? pending.target : null;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("resolveNavigateToAgent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the input workspaceId without reading the nav target", () => {
|
||||
it("uses the input workspaceId only when the store has no record", () => {
|
||||
const readTargets: { serverId: string; agentId: string }[] = [];
|
||||
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null });
|
||||
deps.readAgentNavTarget = (input) => {
|
||||
@@ -75,7 +75,7 @@ describe("resolveNavigateToAgent", () => {
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(readTargets).toEqual([]);
|
||||
expect(readTargets).toEqual([{ serverId: SERVER_ID, agentId: AGENT_ID }]);
|
||||
expect(tabNavigations).toEqual([
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
@@ -86,6 +86,12 @@ describe("resolveNavigateToAgent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers the current agent workspace over a stale input workspace", () => {
|
||||
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: "current" });
|
||||
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: "stale" }, deps);
|
||||
expect(tabNavigations[0]?.workspaceId).toBe("current");
|
||||
});
|
||||
|
||||
it("falls back to the host agent route when the agent has no workspaceId", () => {
|
||||
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: null,
|
||||
|
||||
@@ -26,8 +26,8 @@ export function resolveNavigateToAgent(
|
||||
deps: NavigateToAgentDeps,
|
||||
): string {
|
||||
const agentWorkspaceId =
|
||||
input.workspaceId ??
|
||||
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId;
|
||||
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId })
|
||||
.agentWorkspaceId ?? input.workspaceId;
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||
|
||||
if (!workspaceId) {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
|
||||
|
||||
describe("resolveNotificationTarget", () => {
|
||||
it("extracts non-empty server and agent ids", () => {
|
||||
expect(
|
||||
resolveNotificationTarget({
|
||||
serverId: " server-123 ",
|
||||
agentId: " agent-456 ",
|
||||
}),
|
||||
).toEqual({
|
||||
serverId: "server-123",
|
||||
agentId: "agent-456",
|
||||
workspaceId: null,
|
||||
terminalId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for missing/empty ids", () => {
|
||||
expect(resolveNotificationTarget({ serverId: "", agentId: " " })).toEqual({
|
||||
serverId: null,
|
||||
agentId: null,
|
||||
workspaceId: null,
|
||||
terminalId: null,
|
||||
});
|
||||
expect(resolveNotificationTarget(undefined)).toEqual({
|
||||
serverId: null,
|
||||
agentId: null,
|
||||
workspaceId: null,
|
||||
terminalId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat cwd as a workspace id alias", () => {
|
||||
expect(
|
||||
resolveNotificationTarget({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-1",
|
||||
cwd: "/tmp/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-1",
|
||||
workspaceId: null,
|
||||
terminalId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNotificationRoute", () => {
|
||||
it("routes to the agent path when workspace id is present", () => {
|
||||
expect(
|
||||
buildNotificationRoute({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-1",
|
||||
workspaceId: "ws-main",
|
||||
}),
|
||||
).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-1");
|
||||
});
|
||||
|
||||
it("does not treat an incomplete notification as an agent URL", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe("/h/srv-1");
|
||||
});
|
||||
|
||||
it("routes to the workspace terminal tab when workspace and terminal ids are present", () => {
|
||||
expect(
|
||||
buildNotificationRoute({
|
||||
serverId: "srv-1",
|
||||
workspaceId: "ws-main",
|
||||
terminalId: "term-1",
|
||||
}),
|
||||
).toBe("/h/srv-1/workspace/ws-main?open=terminal%3Aterm-1");
|
||||
});
|
||||
|
||||
it("falls back to host root for a terminal without a workspace id", () => {
|
||||
expect(
|
||||
buildNotificationRoute({
|
||||
serverId: "srv-1",
|
||||
terminalId: "term-1",
|
||||
cwd: "/tmp/repo",
|
||||
}),
|
||||
).toBe("/h/srv-1");
|
||||
});
|
||||
|
||||
it("falls back to host root when only serverId is present", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only");
|
||||
});
|
||||
|
||||
it("falls back to root when no server id is present", () => {
|
||||
expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/");
|
||||
expect(buildNotificationRoute(undefined)).toBe("/");
|
||||
});
|
||||
|
||||
it("encodes path segments", () => {
|
||||
expect(
|
||||
buildNotificationRoute({
|
||||
serverId: "srv/with/slash",
|
||||
workspaceId: "workspace-1",
|
||||
agentId: "agent with space",
|
||||
}),
|
||||
).toBe("/h/srv%2Fwith%2Fslash/workspace/workspace-1?open=agent%3Aagent%20with%20space");
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Href } from "expo-router";
|
||||
import { buildHostRootRoute, buildHostWorkspaceOpenRoute } from "@/utils/host-routes";
|
||||
|
||||
type NotificationData = Record<string, unknown> | null | undefined;
|
||||
type NotificationRoute = Extract<Href, string>;
|
||||
|
||||
function readNonEmptyString(data: NotificationData, key: string): string | null {
|
||||
const value = data?.[key];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function resolveNotificationTarget(data: NotificationData): {
|
||||
serverId: string | null;
|
||||
agentId: string | null;
|
||||
workspaceId: string | null;
|
||||
terminalId: string | null;
|
||||
} {
|
||||
return {
|
||||
serverId: readNonEmptyString(data, "serverId"),
|
||||
agentId: readNonEmptyString(data, "agentId"),
|
||||
workspaceId: readNonEmptyString(data, "workspaceId"),
|
||||
terminalId: readNonEmptyString(data, "terminalId"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNotificationRoute(data: NotificationData): NotificationRoute {
|
||||
const { serverId, agentId, workspaceId, terminalId } = resolveNotificationTarget(data);
|
||||
if (serverId && workspaceId && agentId) {
|
||||
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `agent:${agentId}`);
|
||||
}
|
||||
if (serverId && workspaceId && terminalId) {
|
||||
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `terminal:${terminalId}`);
|
||||
}
|
||||
if (serverId) {
|
||||
return buildHostRootRoute(serverId);
|
||||
}
|
||||
return "/" as const;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WEB_NOTIFICATION_CLICK_EVENT } from "@/notification-open";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface MockNotificationOptions {
|
||||
@@ -132,7 +133,7 @@ describe("sendOsNotification", () => {
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
|
||||
(globalThis as { location?: unknown }).location = { assign };
|
||||
|
||||
const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } = await loadModuleForPlatform("web");
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||
|
||||
const sent = await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
@@ -159,7 +160,7 @@ describe("sendOsNotification", () => {
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to route navigation when no listener handles the click", async () => {
|
||||
it("leaves navigation to the app when no listener handles the click", async () => {
|
||||
const created: MockNotificationInstance[] = [];
|
||||
|
||||
class MockNotification implements MockNotificationInstance {
|
||||
@@ -207,9 +208,7 @@ describe("sendOsNotification", () => {
|
||||
expect(clicked.clickListeners).toHaveLength(1);
|
||||
clicked.clickListeners[0]?.({} as Event);
|
||||
|
||||
expect(assign).toHaveBeenCalledWith(
|
||||
"/h/srv%20with%20space/workspace/workspace-1?open=agent%3Aagent%2F1",
|
||||
);
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when the Notification API is unavailable", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Asset } from "expo-asset";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
|
||||
import { parseNotificationOpenTarget, WEB_NOTIFICATION_CLICK_EVENT } from "@/notification-open";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
interface OsNotificationPayload {
|
||||
@@ -17,8 +17,6 @@ interface WebNotificationInstance {
|
||||
addEventListener: (type: "click", listener: (event: Event) => void) => void;
|
||||
}
|
||||
|
||||
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
||||
|
||||
let permissionRequest: Promise<boolean> | null = null;
|
||||
let notificationIconUrl: string | null | undefined;
|
||||
|
||||
@@ -98,8 +96,7 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
|
||||
}
|
||||
|
||||
function hasNotificationClickTarget(data: Record<string, unknown> | undefined): boolean {
|
||||
const target = resolveNotificationTarget(data);
|
||||
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
|
||||
return parseNotificationOpenTarget(data) !== null;
|
||||
}
|
||||
|
||||
function getWebNotificationIconUrl(): string | undefined {
|
||||
@@ -135,31 +132,12 @@ function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boole
|
||||
return !dispatch(event);
|
||||
}
|
||||
|
||||
function fallbackNavigateToNotificationTarget(data: Record<string, unknown> | undefined): void {
|
||||
const route = buildNotificationRoute(data);
|
||||
const location = (globalThis as { location?: { assign?: (url: string) => void; href?: string } })
|
||||
.location;
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (typeof location.assign === "function") {
|
||||
location.assign(route);
|
||||
return;
|
||||
}
|
||||
if (typeof location.href === "string") {
|
||||
location.href = route;
|
||||
}
|
||||
}
|
||||
|
||||
function attachWebClickHandler(
|
||||
notification: WebNotificationInstance,
|
||||
data: Record<string, unknown> | undefined,
|
||||
): void {
|
||||
notification.addEventListener("click", () => {
|
||||
const handledByApp = dispatchWebNotificationClick({ data });
|
||||
if (!handledByApp) {
|
||||
fallbackNavigateToNotificationTarget(data);
|
||||
}
|
||||
dispatchWebNotificationClick({ data });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,57 @@ describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
expect(manager.checkedProviders).toEqual(["claude", "codex"]);
|
||||
});
|
||||
|
||||
it("logs the provider, model and error of every failed attempt", async () => {
|
||||
const warnings: Array<{ obj: object; msg?: string }> = [];
|
||||
const runnerError = new Error("connection refused");
|
||||
const manager = createManager([
|
||||
{ provider: "local-llm", available: true, error: null },
|
||||
{ provider: "opencode", available: false, error: "not installed" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
schemaName: "BranchName",
|
||||
providers: [
|
||||
{ provider: "local-llm", model: "qwen-local" },
|
||||
{ provider: "opencode", model: "minimax-m3" },
|
||||
],
|
||||
logger: {
|
||||
info: () => undefined,
|
||||
warn: (obj, msg) => warnings.push({ obj, msg }),
|
||||
},
|
||||
runner: async () => {
|
||||
throw runnerError;
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
{
|
||||
obj: {
|
||||
err: runnerError,
|
||||
provider: "local-llm",
|
||||
model: "qwen-local",
|
||||
schemaName: "BranchName",
|
||||
},
|
||||
msg: "Structured generation: provider failed",
|
||||
},
|
||||
{
|
||||
obj: {
|
||||
provider: "opencode",
|
||||
model: "minimax-m3",
|
||||
schemaName: "BranchName",
|
||||
reason: "not installed",
|
||||
},
|
||||
msg: "Structured generation: skipping unavailable provider",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws a fallback error when all providers are unavailable or fail", async () => {
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: false, error: "missing auth" },
|
||||
|
||||
@@ -475,7 +475,7 @@ export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
});
|
||||
logger?.warn(
|
||||
{ err: error, provider: candidate.provider, model: candidate.model, schemaName },
|
||||
"Structured generation: provider failed, trying next",
|
||||
"Structured generation: provider failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class ProviderSnapshots {
|
||||
}
|
||||
|
||||
describe("resolveStructuredGenerationProviders", () => {
|
||||
test("tries the configured model before dynamically discovered fallbacks", async () => {
|
||||
test("tries only the configured models when metadata generation is manual", async () => {
|
||||
const snapshots = new ProviderSnapshots([
|
||||
{
|
||||
provider: "work-claude",
|
||||
@@ -28,6 +28,12 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
{ provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [{ provider: "opencode", id: "minimax-m3", label: "MiniMax M3", isDefault: true }],
|
||||
},
|
||||
]);
|
||||
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
@@ -35,14 +41,18 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
providerSnapshotManager: snapshots,
|
||||
daemonConfig: {
|
||||
metadataGeneration: {
|
||||
providers: [{ provider: "mock", model: "ten-second-stream" }],
|
||||
providers: [
|
||||
{ provider: "local-llm", model: "qwen-local" },
|
||||
{ provider: "mock", model: "ten-second-stream" },
|
||||
],
|
||||
},
|
||||
},
|
||||
currentSelection: { provider: "opencode", model: "minimax-m3" },
|
||||
});
|
||||
|
||||
expect(providers).toEqual([
|
||||
{ provider: "local-llm", model: "qwen-local" },
|
||||
{ provider: "mock", model: "ten-second-stream" },
|
||||
{ provider: "work-claude", model: "claude-haiku-2026" },
|
||||
]);
|
||||
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
|
||||
});
|
||||
@@ -227,10 +237,7 @@ describe("resolveStructuredGenerationProviders", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers).toEqual([
|
||||
{ provider: "current-provider", model: "configured-model" },
|
||||
{ provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" },
|
||||
]);
|
||||
expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]);
|
||||
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,10 @@ export interface ResolveStructuredGenerationProvidersOptions {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-empty configured list means Manual: only those models are tried, in order.
|
||||
* An empty list means Automatic: built-in defaults, then the current selection.
|
||||
*/
|
||||
export async function resolveStructuredGenerationProviders(
|
||||
options: ResolveStructuredGenerationProvidersOptions,
|
||||
): Promise<StructuredGenerationProvider[]> {
|
||||
@@ -53,16 +57,18 @@ export async function resolveStructuredGenerationProviders(
|
||||
const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry]));
|
||||
const providers: StructuredGenerationProvider[] = [];
|
||||
|
||||
for (const configured of configuredProviders) {
|
||||
const resolvedConfigured = resolveConfiguredCandidate(
|
||||
configured,
|
||||
modelEntries,
|
||||
entriesByProvider,
|
||||
);
|
||||
if (!resolvedConfigured) {
|
||||
continue;
|
||||
if (configuredProviders.length > 0) {
|
||||
for (const configured of configuredProviders) {
|
||||
const resolvedConfigured = resolveConfiguredCandidate(
|
||||
configured,
|
||||
modelEntries,
|
||||
entriesByProvider,
|
||||
);
|
||||
if (resolvedConfigured) {
|
||||
providers.push(resolvedConfigured);
|
||||
}
|
||||
}
|
||||
providers.push(resolvedConfigured);
|
||||
return dedupeProviders(providers);
|
||||
}
|
||||
|
||||
for (const identifier of DEFAULT_STRUCTURED_GENERATION_PROVIDERS) {
|
||||
|
||||
@@ -973,7 +973,9 @@ export class Session {
|
||||
providerSnapshotManager,
|
||||
readDaemonConfig: () => this.readStructuredGenerationDaemonConfig(),
|
||||
getFocusedSelection: (cwd) => this.getFocusedAgentSelectionForCwd(cwd),
|
||||
logger: this.sessionLogger,
|
||||
}),
|
||||
logger: this.sessionLogger,
|
||||
}),
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CheckoutDiffCompare, CheckoutDiffResult } from "../../../utils/che
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import {
|
||||
createGitMetadataGenerator,
|
||||
type GitMetadataGenerator,
|
||||
type StructuredTextGeneration,
|
||||
type StructuredTextGenerationRequest,
|
||||
} from "./git-metadata-generator.js";
|
||||
@@ -38,6 +39,18 @@ function createGeneration(handler: (request: StructuredTextGenerationRequest<unk
|
||||
return { generation, generateCalls };
|
||||
}
|
||||
|
||||
function createLogger() {
|
||||
const errors: Array<{ obj: object; msg?: string }> = [];
|
||||
return {
|
||||
errors,
|
||||
logger: { error: (obj: object, msg?: string) => errors.push({ obj, msg }) },
|
||||
};
|
||||
}
|
||||
|
||||
const MANUAL_MODEL_FAILURE = new StructuredAgentFallbackError([
|
||||
{ provider: "local-llm", model: "qwen-local", available: true, error: "connection refused" },
|
||||
]);
|
||||
|
||||
const DIFF_WITH_ONE_FILE: CheckoutDiffResult = {
|
||||
diff: "diff --git a/src/foo.ts b/src/foo.ts\n+added\n",
|
||||
structured: [
|
||||
@@ -59,7 +72,11 @@ describe("createGitMetadataGenerator", () => {
|
||||
const { generation, generateCalls } = createGeneration(() => ({
|
||||
message: "Fix the flaky retry test",
|
||||
}));
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger: createLogger().logger,
|
||||
});
|
||||
|
||||
const message = await generator.generateCommitMessage("/repo");
|
||||
|
||||
@@ -82,7 +99,11 @@ describe("createGitMetadataGenerator", () => {
|
||||
const { generation } = createGeneration(() => {
|
||||
throw new StructuredAgentFallbackError([]);
|
||||
});
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger: createLogger().logger,
|
||||
});
|
||||
|
||||
await expect(generator.generateCommitMessage("/repo")).resolves.toBe("Update files");
|
||||
});
|
||||
@@ -95,7 +116,11 @@ describe("createGitMetadataGenerator", () => {
|
||||
validationErrors: ["message: required"],
|
||||
});
|
||||
});
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger: createLogger().logger,
|
||||
});
|
||||
|
||||
await expect(generator.generateCommitMessage("/repo")).resolves.toBe("Update files");
|
||||
});
|
||||
@@ -105,7 +130,11 @@ describe("createGitMetadataGenerator", () => {
|
||||
const { generation } = createGeneration(() => {
|
||||
throw new Error("network down");
|
||||
});
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger: createLogger().logger,
|
||||
});
|
||||
|
||||
await expect(generator.generateCommitMessage("/repo")).rejects.toThrow("network down");
|
||||
});
|
||||
@@ -116,7 +145,11 @@ describe("createGitMetadataGenerator", () => {
|
||||
title: "Add retry with backoff",
|
||||
body: "Retries transient failures up to twice.",
|
||||
}));
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger: createLogger().logger,
|
||||
});
|
||||
|
||||
const result = await generator.generatePullRequestText("/repo", "main");
|
||||
|
||||
@@ -140,11 +173,60 @@ describe("createGitMetadataGenerator", () => {
|
||||
const { generation } = createGeneration(() => {
|
||||
throw new StructuredAgentFallbackError([]);
|
||||
});
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger: createLogger().logger,
|
||||
});
|
||||
|
||||
await expect(generator.generatePullRequestText("/repo")).resolves.toEqual({
|
||||
title: "Update changes",
|
||||
body: "Automated PR generated by Paseo.",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"generateCommitMessage",
|
||||
"CommitMessage",
|
||||
(generator: GitMetadataGenerator) => generator.generateCommitMessage("/repo"),
|
||||
],
|
||||
[
|
||||
"generatePullRequestText",
|
||||
"PullRequest",
|
||||
(generator: GitMetadataGenerator) => generator.generatePullRequestText("/repo", "main"),
|
||||
],
|
||||
] as const)("%s logs the failed provider, model and error", async (_name, schemaName, run) => {
|
||||
const { diffSource } = createDiffSource(DIFF_WITH_ONE_FILE);
|
||||
const { generation } = createGeneration(() => {
|
||||
throw MANUAL_MODEL_FAILURE;
|
||||
});
|
||||
const { logger, errors } = createLogger();
|
||||
const generator = createGitMetadataGenerator({
|
||||
workspaceGitService: diffSource,
|
||||
generation,
|
||||
logger,
|
||||
});
|
||||
|
||||
await run(generator);
|
||||
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
obj: {
|
||||
err: MANUAL_MODEL_FAILURE,
|
||||
cwd: "/repo",
|
||||
schemaName,
|
||||
attempts: [
|
||||
{
|
||||
provider: "local-llm",
|
||||
model: "qwen-local",
|
||||
available: true,
|
||||
error: "connection refused",
|
||||
},
|
||||
],
|
||||
},
|
||||
msg: "Structured git metadata generation failed",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
type StructuredGenerationLogger,
|
||||
} from "../../agent/agent-response-loop.js";
|
||||
import type { AgentManager } from "../../agent/agent-manager.js";
|
||||
import type { ProviderSnapshotManager } from "../../agent/provider-snapshot-manager.js";
|
||||
@@ -37,7 +38,7 @@ export interface GitMetadataGenerator {
|
||||
* behaviour is unit-testable with a fake. Production wires
|
||||
* createAgentStructuredTextGeneration (resolve providers → run structured
|
||||
* generation); a failed generation throws StructuredAgent*Error, which the
|
||||
* generator catches and turns into the product fallback text.
|
||||
* generator logs and turns into the product fallback text.
|
||||
*/
|
||||
export interface StructuredTextGeneration {
|
||||
generate<T>(request: StructuredTextGenerationRequest<T>): Promise<T>;
|
||||
@@ -51,6 +52,10 @@ export interface StructuredTextGenerationRequest<T> {
|
||||
agentTitle: string;
|
||||
}
|
||||
|
||||
interface GitMetadataLogger {
|
||||
error: (obj: object, msg?: string) => void;
|
||||
}
|
||||
|
||||
type GitMetadataDiffSource = Pick<WorkspaceGitService, "getCheckoutDiff" | "resolveRepoRoot">;
|
||||
type CheckoutDiffOptions = Parameters<GitMetadataDiffSource["getCheckoutDiff"]>[1];
|
||||
type CheckoutDiff = Awaited<ReturnType<GitMetadataDiffSource["getCheckoutDiff"]>>;
|
||||
@@ -90,8 +95,32 @@ interface PromptForDiffInput {
|
||||
export function createGitMetadataGenerator(deps: {
|
||||
workspaceGitService: GitMetadataDiffSource;
|
||||
generation: StructuredTextGeneration;
|
||||
logger: GitMetadataLogger;
|
||||
}): GitMetadataGenerator {
|
||||
const { workspaceGitService, generation } = deps;
|
||||
const { workspaceGitService, generation, logger } = deps;
|
||||
|
||||
async function generateOrFallback<T>(
|
||||
request: StructuredTextGenerationRequest<T>,
|
||||
fallback: T,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await generation.generate(request);
|
||||
} catch (error) {
|
||||
if (!isStructuredGenerationFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
cwd: request.cwd,
|
||||
schemaName: request.schemaName,
|
||||
attempts: error instanceof StructuredAgentFallbackError ? error.attempts : undefined,
|
||||
},
|
||||
"Structured git metadata generation failed",
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildPromptForDiff(input: PromptForDiffInput): Promise<string> {
|
||||
const diff = await workspaceGitService.getCheckoutDiff(input.cwd, input.diffOptions);
|
||||
@@ -123,21 +152,17 @@ export function createGitMetadataGenerator(deps: {
|
||||
styleDefault: "Concise, imperative mood, no trailing period.",
|
||||
jsonFieldsHint: "Return JSON only with a single field 'message'.",
|
||||
});
|
||||
try {
|
||||
const result = await generation.generate({
|
||||
const result = await generateOrFallback(
|
||||
{
|
||||
cwd,
|
||||
prompt,
|
||||
schema: COMMIT_MESSAGE_SCHEMA,
|
||||
schemaName: "CommitMessage",
|
||||
agentTitle: "Commit generator",
|
||||
});
|
||||
return result.message;
|
||||
} catch (error) {
|
||||
if (isStructuredGenerationFailure(error)) {
|
||||
return COMMIT_MESSAGE_FALLBACK;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ message: COMMIT_MESSAGE_FALLBACK },
|
||||
);
|
||||
return result.message;
|
||||
},
|
||||
|
||||
async generatePullRequestText(cwd, baseRef) {
|
||||
@@ -150,20 +175,16 @@ export function createGitMetadataGenerator(deps: {
|
||||
styleDefault: "Clear, descriptive title; body explaining what changed and why.",
|
||||
jsonFieldsHint: "Return JSON only with fields 'title' and 'body'.",
|
||||
});
|
||||
try {
|
||||
return await generation.generate({
|
||||
return generateOrFallback(
|
||||
{
|
||||
cwd,
|
||||
prompt,
|
||||
schema: PULL_REQUEST_SCHEMA,
|
||||
schemaName: "PullRequest",
|
||||
agentTitle: "PR generator",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isStructuredGenerationFailure(error)) {
|
||||
return PULL_REQUEST_FALLBACK;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
PULL_REQUEST_FALLBACK,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -180,6 +201,7 @@ export function createAgentStructuredTextGeneration(deps: {
|
||||
getFocusedSelection: (
|
||||
cwd: string,
|
||||
) => ResolveStructuredGenerationProvidersOptions["currentSelection"];
|
||||
logger: StructuredGenerationLogger;
|
||||
}): StructuredTextGeneration {
|
||||
return {
|
||||
async generate({ cwd, prompt, schema, schemaName, agentTitle }) {
|
||||
@@ -198,6 +220,7 @@ export function createAgentStructuredTextGeneration(deps: {
|
||||
maxRetries: 2,
|
||||
providers,
|
||||
persistSession: false,
|
||||
logger: deps.logger,
|
||||
agentConfigOverrides: {
|
||||
title: agentTitle,
|
||||
internal: true,
|
||||
@@ -234,7 +257,9 @@ function diffChangeTypeFor(file: { isNew?: boolean; isDeleted?: boolean }): "A"
|
||||
return "M";
|
||||
}
|
||||
|
||||
function isStructuredGenerationFailure(error: unknown): boolean {
|
||||
function isStructuredGenerationFailure(
|
||||
error: unknown,
|
||||
): error is StructuredAgentResponseError | StructuredAgentFallbackError {
|
||||
return (
|
||||
error instanceof StructuredAgentResponseError || error instanceof StructuredAgentFallbackError
|
||||
);
|
||||
|
||||
@@ -4,7 +4,10 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { StructuredAgentGenerationWithFallbackOptions } from "./agent/agent-response-loop.js";
|
||||
import {
|
||||
StructuredAgentFallbackError,
|
||||
type StructuredAgentGenerationWithFallbackOptions,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
import {
|
||||
attemptFirstAgentBranchAutoName,
|
||||
type AttemptFirstAgentBranchAutoNameResult,
|
||||
@@ -227,6 +230,52 @@ describe("generateBranchNameFromFirstAgentContext", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("tries only the manual model and logs its failure", async () => {
|
||||
const logger = createLogger();
|
||||
const failure = new StructuredAgentFallbackError([
|
||||
{ provider: "local-llm", model: "qwen-local", available: true, error: "connection refused" },
|
||||
]);
|
||||
const calls: StructuredAgentGenerationWithFallbackOptions<unknown>[] = [];
|
||||
|
||||
const result = await generateBranchNameFromFirstAgentContext({
|
||||
agentManager: {} as AgentManager,
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: {
|
||||
listProviders: vi.fn(async () => [
|
||||
{
|
||||
provider: "opencode",
|
||||
status: "ready" as const,
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "opencode", id: "minimax-m3", label: "MiniMax M3", isDefault: true },
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
daemonConfig: {
|
||||
metadataGeneration: { providers: [{ provider: "local-llm", model: "qwen-local" }] },
|
||||
},
|
||||
currentSelection: { provider: "opencode", model: "minimax-m3" },
|
||||
firstAgentContext: { prompt: "Fix the login flow" },
|
||||
logger,
|
||||
deps: {
|
||||
generateStructuredAgentResponseWithFallback: async (options) => {
|
||||
calls.push(options as StructuredAgentGenerationWithFallbackOptions<unknown>);
|
||||
throw failure;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(calls.map((call) => call.providers)).toEqual([
|
||||
[{ provider: "local-llm", model: "qwen-local" }],
|
||||
]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: failure, attempts: failure.attempts },
|
||||
"Structured branch name generation failed",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["paseo.json missing", undefined],
|
||||
["paseo.json exists but invalid JSON", "{ nope"],
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { searchDirectoryEntries } from "./directory-suggestions.js";
|
||||
|
||||
it("suggests an exact filename beyond the default scan budget", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-search-3694-"));
|
||||
try {
|
||||
for (let i = 0; i < 201; i += 1) {
|
||||
const folder = path.join(root, `package-${String(i).padStart(3, "0")}`);
|
||||
mkdirSync(folder);
|
||||
for (let j = 0; j < 100; j += 1) writeFileSync(path.join(folder, `file-${j}.ts`), "");
|
||||
}
|
||||
const target = "package-200/xhci-ring.c";
|
||||
writeFileSync(path.join(root, target), "");
|
||||
const options = {
|
||||
root,
|
||||
query: "xhci-ring",
|
||||
pathFormat: "relative" as const,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
};
|
||||
const bounded = await searchDirectoryEntries(options);
|
||||
const unbounded = await searchDirectoryEntries({ ...options, maxEntriesScanned: 100_000 });
|
||||
console.log(JSON.stringify({ bounded, unbounded }));
|
||||
expect(unbounded).toContainEqual({ path: target, kind: "file" });
|
||||
expect(bounded).toContainEqual({ path: target, kind: "file" });
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -269,7 +269,7 @@
|
||||
color: color-mix(in srgb, var(--color-foreground) 78%, var(--color-primary));
|
||||
}
|
||||
|
||||
.changelog-release-notes code {
|
||||
.changelog-release-notes :not(pre) > code {
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
padding: 0.125rem 0.375rem;
|
||||
@@ -522,6 +522,7 @@
|
||||
width: max(22%, 7.5rem);
|
||||
}
|
||||
|
||||
.changelog-release-notes pre,
|
||||
.docs-prose pre {
|
||||
background-color: var(--color-card) !important;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -540,6 +541,7 @@
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.changelog-release-notes pre code,
|
||||
.docs-prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
|
||||
@@ -15,8 +15,9 @@ The panel is a CodeMirror editor with Markdown highlighting, line numbers, undo,
|
||||
wrapping. It reads the file once with `readText`, saves 500 ms after the last edit with
|
||||
`writeText`, and reports unsaved edits with `tab.setModified`. When the file changes on disk with
|
||||
nothing unsaved, it reads again; with unsaved edits, or when a write rejects with
|
||||
`PluginFileConflictError`, it offers **Reload** and **Overwrite**. The bar's switcher still offers
|
||||
Paseo's Markdown and Source panels. Native shows selectable, read-only text through `useFileText`.
|
||||
`PluginFileConflictError`, it offers **Reload** and **Overwrite**. **Open with…** in the tab menu
|
||||
switches the tab back to Paseo's Markdown panel. Native shows selectable, read-only text through
|
||||
`useFileText`.
|
||||
|
||||
Paseo bundles the editor dependencies into this plugin. They are loaded only on web; the native
|
||||
path uses React Native primitives. DOM integration lives in `client/web.ts`.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user