mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
250da262f4 | ||
|
|
71f575e16a | ||
|
|
0708b8295c | ||
|
|
4d5c3c13d4 | ||
|
|
d3efddb0b5 | ||
|
|
0ed57187a9 | ||
|
|
a96c892302 | ||
|
|
74282dac12 | ||
|
|
12066431ad | ||
|
|
4d8bed9ddd | ||
|
|
b2ca7dcf03 | ||
|
|
8b1ba3ca58 | ||
|
|
e5124b039a | ||
|
|
5c108f345c | ||
|
|
9ab99a6e1e | ||
|
|
8ff2424da1 | ||
|
|
6db47b3c37 | ||
|
|
73221ce5bd |
+13
-4
@@ -53,6 +53,17 @@
|
||||
- **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。
|
||||
- **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。
|
||||
|
||||
## スポンサー
|
||||
|
||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo のスポンサーになる</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo のスポンサーになる</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## はじめかた
|
||||
|
||||
Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。
|
||||
@@ -151,11 +162,9 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## スポンサー
|
||||
## Paseo を支援する
|
||||
|
||||
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをこの README の冒頭と、paseo.sh のトップページおよびドキュメントに掲載します。
|
||||
|
||||
## 関連プロジェクト
|
||||
|
||||
|
||||
+13
-4
@@ -47,6 +47,17 @@
|
||||
- **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다.
|
||||
- **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다.
|
||||
|
||||
## 스폰서
|
||||
|
||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo 스폰서 되기</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo 스폰서 되기</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 시작하기
|
||||
|
||||
Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다.
|
||||
@@ -161,11 +172,9 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 스폰서
|
||||
## Paseo 후원하기
|
||||
|
||||
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이 README 상단과 paseo.sh 홈페이지 및 문서에 게재할 수 있습니다.
|
||||
|
||||
## 관련 프로젝트
|
||||
|
||||
|
||||
@@ -52,6 +52,17 @@ Paseo is an open source agentic development environment for desktop, mobile, web
|
||||
|
||||
[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser.
|
||||
|
||||
## Sponsors
|
||||
|
||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Plugins
|
||||
|
||||
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
|
||||
@@ -207,11 +218,9 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
## Support Paseo
|
||||
|
||||
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown at the top of this README and on the paseo.sh homepage and docs.
|
||||
|
||||
## Related projects
|
||||
|
||||
|
||||
+13
-4
@@ -53,6 +53,17 @@
|
||||
- **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作,用手机查看进度,也可以从终端脚本化操作。
|
||||
- **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。
|
||||
|
||||
## 赞助商
|
||||
|
||||
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 快速开始
|
||||
|
||||
Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。
|
||||
@@ -151,11 +162,9 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 赞助
|
||||
## 支持 Paseo
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
|
||||
|
||||
## 相关项目
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in the [workspace sheet](explorer-sidebar.md#workspace-sheet) instead; the sheet hides the composer, and a Paseo subagent's sheet offers **Open as tab** to get it back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
+21
-14
@@ -7,7 +7,7 @@ contracts.
|
||||
| ---------------- | ---------------------------- | ------------------------------------------ |
|
||||
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
|
||||
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
|
||||
| Workspace sheet | Compact look-ups | Stacks tabs; each closes or becomes a tab |
|
||||
| Sheets | Compact look-ups | One tab each, on the app-wide sheet stack |
|
||||
|
||||
## Panel host contract
|
||||
|
||||
@@ -23,23 +23,30 @@ can move between Explorer and main panes.
|
||||
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
|
||||
## Workspace sheet
|
||||
## Sheets
|
||||
|
||||
On compact layouts, files and subagents open in a closable sheet over the workspace, so the tab
|
||||
switcher keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
|
||||
On compact layouts, files and subagents open in a sheet over the workspace, so the tab switcher
|
||||
keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
|
||||
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
|
||||
sheet and tab, so callers open targets the same way on every layout. Each sheet holds one ordinary
|
||||
`WorkspaceTab`, and sheets stack: a target opened from a sheet covers it, and closing the top sheet
|
||||
(drag, Close, backdrop, or Android Back) uncovers the one below. Revealing a target that already has
|
||||
a sheet brings that sheet back to the top and closes the sheets above it, except ones holding unsaved
|
||||
work. Opening a sheet as a tab dismisses the sheets above it, since the
|
||||
tab would sit under them. The stack is not persisted.
|
||||
sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one
|
||||
ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every
|
||||
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a
|
||||
subagent chosen from the subagents list included, lands on top of it, and closing the top sheet
|
||||
(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the
|
||||
sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract.
|
||||
|
||||
The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet
|
||||
remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the
|
||||
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
|
||||
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
|
||||
|
||||
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
|
||||
handle, because the panel scrolls on its own and content panning would fight it. Sheets render in
|
||||
the workspace tree rather than through `IsolatedBottomSheetModal`: their panels need the navigator
|
||||
and every workspace provider an ordinary pane has, and a portal drops them
|
||||
([floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site)).
|
||||
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
|
||||
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
|
||||
Close, so the title keeps its width. The sheets present
|
||||
from inside the compact column's `SheetHostScope`, so their panels read the navigator and every
|
||||
workspace provider an ordinary pane has without a context bridge
|
||||
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
|
||||
|
||||
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
|
||||
Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
|
||||
|
||||
+26
-9
@@ -217,7 +217,14 @@ sheet suspends pane focus, and the composer gives up input from the moment the
|
||||
user asks for the compact agent list or explorer, before the panel's motion
|
||||
settles. Pane focus itself stays put under those panels: the agent panel reads
|
||||
it as "the user is in this chat" for attention clearing, and opening the agent
|
||||
list must not clear the attention it shows.
|
||||
list must not clear the attention it shows. Focus restoration follows pane
|
||||
focus, not the active composer. The editor keeps its focus intent only across
|
||||
a blur that lands while its pane is not the user's (a retained tab hidden, a
|
||||
sheet over it) and refocuses when the pane comes back. A blur while the pane is
|
||||
theirs drops the intent in every layout mode, and the keyboard the compact
|
||||
panels dismiss when they settle is such a blur, so closing the agent list or
|
||||
explorer never reopens the keyboard. Keying restoration on the active composer
|
||||
made that dismiss look like a hidden-tab blur and refocused on close.
|
||||
The band beside the inline editor where the fullscreen glyph sits is the input's
|
||||
own right padding, so a tap anywhere in it focuses the editor whether or not the
|
||||
glyph shows. On web the slot is a fixed box over the visible viewport, which is
|
||||
@@ -336,11 +343,20 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
|
||||
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
|
||||
track phase.
|
||||
|
||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
|
||||
includes native centered dialogs: separate React Native Modals cannot present a root-owned
|
||||
sibling dialog over an existing iOS presenter. The one exception is the
|
||||
[workspace sheet](explorer-sidebar.md#workspace-sheet), an in-tree Gorhom `BottomSheet` that hosts a
|
||||
whole workspace panel.
|
||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no
|
||||
exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on
|
||||
close. This includes native centered dialogs: separate React Native Modals cannot present a
|
||||
root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree
|
||||
(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their
|
||||
panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the
|
||||
portal host and container measurement while the sheet still joins the same stack. That export is
|
||||
what `patches/@gorhom+bottom-sheet+*.patch` adds.
|
||||
|
||||
A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every
|
||||
close path then parks the sheet at index -1 with its content mounted; the owner hears
|
||||
`onChange(-1)`, runs its guard, and either snaps back to 0 or drops its state, which unmounts the
|
||||
sheet and takes it off the stack. Calling `dismiss()` on such a sheet bypasses the guard and
|
||||
unmounts it, so its backdrop and Back press call `close()`.
|
||||
|
||||
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
|
||||
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
|
||||
@@ -350,9 +366,10 @@ forwards commands to the current Gorhom handle.
|
||||
Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web,
|
||||
Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated
|
||||
pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop
|
||||
keeps its press target until portal teardown and asks the provider to dismiss the top modal.
|
||||
Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native
|
||||
retains Gorhom's gesture backdrop and the same push/replace presentation semantics.
|
||||
keeps its press target until portal teardown and asks the provider to dismiss the top modal
|
||||
(a guarded sheet closes itself instead, see above). Gorhom's presentation queue remains the
|
||||
authority; backdrop mount order is irrelevant. Native retains Gorhom's gesture backdrop and the
|
||||
same push/replace presentation semantics.
|
||||
|
||||
## Gotcha 7 — A sheet cannot read context from its call site
|
||||
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Workspace sheet** — A compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher; opening another target from a sheet stacks a new one on top. UI action: "Open as tab". Code: `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet).
|
||||
- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md).
|
||||
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
|
||||
@@ -52,6 +52,11 @@ is another, with its own icons and hover-revealed actions — but both hand the
|
||||
and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press
|
||||
outright leaves the surface open behind whatever it opened, and skips the iOS wait.
|
||||
|
||||
A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu
|
||||
sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab
|
||||
closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout
|
||||
store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout.
|
||||
|
||||
## Pages
|
||||
|
||||
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
|
||||
@@ -193,6 +198,9 @@ its own.
|
||||
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
|
||||
- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only
|
||||
request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth).
|
||||
- **A menu closes when its panel leaves the screen.** `MenuSurface` closes the menu when its
|
||||
retained panel goes inactive. Hiding the surface alone leaves the open state behind, and the
|
||||
menu would reappear with the panel on the next tab switch.
|
||||
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
|
||||
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
|
||||
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../support/helpers/assistant-images";
|
||||
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import path from "node:path";
|
||||
|
||||
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
||||
imageWorkspace: async ({ page: _page }, provide) => {
|
||||
@@ -28,6 +29,42 @@ const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
||||
},
|
||||
});
|
||||
|
||||
test("assistant images at paths with spaces, Unicode and literal percent escapes render", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Angle destination image",
|
||||
fileName: "My image Мои.png",
|
||||
});
|
||||
const agent = await createSettledMockAgent(workspace, "Encoded image paths");
|
||||
await emitSettledAssistantImage(workspace.client, agent, image, `<${image.relativePath}>`);
|
||||
await openAssistantImageTimeline(page, agent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
|
||||
const encodedImage = { ...image, alt: "Encoded destination image" };
|
||||
await emitSettledAssistantImage(
|
||||
workspace.client,
|
||||
agent,
|
||||
encodedImage,
|
||||
encodeURI(path.join(workspace.repoPath, image.relativePath)),
|
||||
);
|
||||
await expectAssistantImageRendered(page, encodedImage);
|
||||
|
||||
const literalPercentImage = await createSmallAssistantPng(workspace, {
|
||||
alt: "Literal percent filename image",
|
||||
fileName: "literal%20name.png",
|
||||
});
|
||||
await emitSettledAssistantImage(
|
||||
workspace.client,
|
||||
agent,
|
||||
literalPercentImage,
|
||||
encodeURI(literalPercentImage.relativePath),
|
||||
);
|
||||
await expectAssistantImageRendered(page, literalPercentImage);
|
||||
});
|
||||
|
||||
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
async function openDefaultAppearance(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ codeFontSize: 12 }));
|
||||
const observations: unknown[] = [];
|
||||
Object.assign(window, { fontRaceObservations: observations });
|
||||
for (const type of ["input", "change", "keydown", "focusin", "focusout"]) {
|
||||
document.addEventListener(
|
||||
type,
|
||||
(event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input?.getAttribute?.("aria-label")?.startsWith("Code font")) return;
|
||||
observations.push({
|
||||
type,
|
||||
label: input.getAttribute("aria-label"),
|
||||
value: input.value,
|
||||
key: (event as KeyboardEvent).key,
|
||||
at: performance.now(),
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
new MutationObserver((records) => {
|
||||
for (const record of records)
|
||||
for (const removed of record.removedNodes) {
|
||||
if (
|
||||
removed instanceof Element &&
|
||||
(removed.matches('[aria-label="Code font size"]') ||
|
||||
removed.querySelector('[aria-label="Code font size"]'))
|
||||
)
|
||||
observations.push({ type: "removed-size-input", at: performance.now() });
|
||||
}
|
||||
}).observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
await page.goto("/settings");
|
||||
await openSettingsSection(page, "appearance");
|
||||
}
|
||||
|
||||
async function changeCodeFamilyThenSize(page: Page) {
|
||||
await page.getByLabel("Code font family").fill("Courier New, Courier, monospace");
|
||||
await page.getByLabel("Code font family").press("Enter");
|
||||
await page.getByLabel("Code font size").fill("18");
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
}
|
||||
|
||||
test("keeps the size entered immediately after changing the code font family", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openDefaultAppearance(page);
|
||||
try {
|
||||
await changeCodeFamilyThenSize(page);
|
||||
await expect(page.getByLabel("Code font size")).toHaveValue("18");
|
||||
} finally {
|
||||
await testInfo.attach("font-race", {
|
||||
body: JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
events: (window as Window & { fontRaceObservations: unknown[] }).fontRaceObservations,
|
||||
stored: localStorage.getItem("@paseo:app-settings"),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4,18 +4,23 @@ import { expect, test } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openSubagentFromCompactTrack, seedSubagent } from "../support/helpers/subagents";
|
||||
import {
|
||||
closeWorkspaceSheet,
|
||||
expectSubagentRowVisible,
|
||||
openSubagentFromCompactTrack,
|
||||
seedSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
import {
|
||||
closeSheet,
|
||||
expectOpenTabCount,
|
||||
openWorkspaceSheetAsTab,
|
||||
openSheetAsTab,
|
||||
expectDeclinedDiscardPrompt,
|
||||
expectWorkspaceSheetAtSnap,
|
||||
replaceWorkspaceSheetFileText,
|
||||
expectSheetAtSnap,
|
||||
replaceSheetFileText,
|
||||
selectTabFromSwitcher,
|
||||
tapWorkspaceSheetBackdrop,
|
||||
workspaceSheet,
|
||||
} from "../support/helpers/workspace-sheet";
|
||||
sheet,
|
||||
sheetHeader,
|
||||
tapSheetBackdrop,
|
||||
} from "../support/helpers/sheet";
|
||||
|
||||
const FILE_LINK = "linked.ts:3";
|
||||
|
||||
@@ -55,47 +60,90 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
const sheet = workspaceSheet(page, "linked.ts");
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText("three")).toBeVisible();
|
||||
await expectWorkspaceSheetAtSnap(page, "linked.ts");
|
||||
await expect(sheet.getByRole("button", { name: "Open as tab" })).toHaveCount(0);
|
||||
const fileSheet = sheet(page, "linked.ts");
|
||||
await expect(fileSheet).toBeVisible();
|
||||
await expect(fileSheet.getByText("three")).toBeVisible();
|
||||
await expectSheetAtSnap(page, "linked.ts");
|
||||
await expect(
|
||||
sheetHeader(page, "linked.ts").getByRole("button", { name: "Open as tab" }),
|
||||
).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
await closeWorkspaceSheet(page, "linked.ts");
|
||||
await closeSheet(page, "linked.ts");
|
||||
});
|
||||
|
||||
await test.step("a subagent opens in a sheet without its composer", async () => {
|
||||
await test.step("a subagent opens in a sheet over the subagents list", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
|
||||
const sheet = workspaceSheet(page, child.title);
|
||||
await expect(sheet).toBeVisible();
|
||||
await expectWorkspaceSheetAtSnap(page, child.title);
|
||||
await expect(sheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expect(childSheet).toBeVisible();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
|
||||
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
|
||||
// seeded agent has no context usage yet.
|
||||
await expect(childSheet.getByTestId("agent-pane-subtitle")).toHaveText(
|
||||
"Ten second stream · Low · Load test",
|
||||
);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab", async () => {
|
||||
await openWorkspaceSheetAsTab(page, child.title);
|
||||
await test.step("closing the subagent's sheet uncovers the list", async () => {
|
||||
await closeSheet(page, child.title);
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await openSheetAsTab(page, child.title);
|
||||
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 2);
|
||||
await expectComposerVisible(page);
|
||||
});
|
||||
|
||||
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
|
||||
await test.step("the list the subagent was opened from closed with its tab", async () => {
|
||||
await selectTabFromSwitcher(page, "Sheet parent");
|
||||
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
|
||||
await page
|
||||
.getByRole("button", { name: /Switch tabs/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
|
||||
const menuBase = `workspace-tab-menu-agent_${child.id}`;
|
||||
await page.getByTestId(`${menuBase}-trigger`).click();
|
||||
await page.getByTestId(`${menuBase}-close`).click();
|
||||
await expectOpenTabCount(page, 1);
|
||||
// Closing a tab keeps the switcher open, with the parent as the tab left. Its backdrop is
|
||||
// tapped once the row menu's own sheet has gone.
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
|
||||
await tapSheetBackdrop(page);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await closeSheet(page, child.title);
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
await tapSheetBackdrop(page);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
|
||||
await page.getByText(FILE_LINK, { exact: true }).click();
|
||||
const sheet = workspaceSheet(page, "linked.ts");
|
||||
await expect(sheet).toBeVisible();
|
||||
const fileSheet = sheet(page, "linked.ts");
|
||||
await expect(fileSheet).toBeVisible();
|
||||
// Hold the autosave so the edit stays unsaved.
|
||||
gate.holdNextClientRequest("fs.file.write.request");
|
||||
await replaceWorkspaceSheetFileText(page, "linked.ts", "edited in the sheet");
|
||||
await replaceSheetFileText(page, "linked.ts", "edited in the sheet");
|
||||
await gate.waitForHeldClientRequest();
|
||||
|
||||
await expectDeclinedDiscardPrompt(page, () => tapWorkspaceSheetBackdrop(page));
|
||||
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||
await expectDeclinedDiscardPrompt(page, () => tapSheetBackdrop(page));
|
||||
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||
await expectDeclinedDiscardPrompt(page, () =>
|
||||
sheet.getByRole("button", { name: "Close" }).click(),
|
||||
sheetHeader(page, "linked.ts").getByRole("button", { name: "Close" }).click(),
|
||||
);
|
||||
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||
|
||||
gate.releaseHeldClientRequest();
|
||||
await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
composerLocator,
|
||||
blurComposerInput,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
expectComposerDraft,
|
||||
expectComposerFocused,
|
||||
expectComposerFullscreenControls,
|
||||
expectComposerNotFocused,
|
||||
expectComposerVisible,
|
||||
expectWorkspaceHeaderVisible,
|
||||
resizeComposerViewport,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
closeMobileExplorer,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectMobileExplorerHidden,
|
||||
expectMobileExplorerVisible,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
swipeOpenMobileExplorer,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
@@ -76,3 +93,76 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const COMPACT_PANELS = [
|
||||
{
|
||||
name: "agent list",
|
||||
open: swipeOpenMobileAgentSidebar,
|
||||
expectOpen: expectMobileAgentSidebarVisible,
|
||||
close: closeMobileAgentSidebar,
|
||||
expectClosed: expectMobileAgentSidebarHidden,
|
||||
},
|
||||
{
|
||||
name: "Explorer",
|
||||
open: swipeOpenMobileExplorer,
|
||||
expectOpen: expectMobileExplorerVisible,
|
||||
close: closeMobileExplorer,
|
||||
expectClosed: expectMobileExplorerHidden,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const panel of COMPACT_PANELS) {
|
||||
test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-panel-",
|
||||
title: `Composer focus under ${panel.name}`,
|
||||
});
|
||||
const draft = "Line one\nLine two\nLine three";
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await test.step("an untouched editor stays untouched", async () => {
|
||||
await expectComposerNotFocused(page);
|
||||
await panel.open(page);
|
||||
await panel.expectOpen(page);
|
||||
await panel.close(page);
|
||||
await panel.expectClosed(page);
|
||||
await expectComposerNotFocused(page);
|
||||
});
|
||||
|
||||
await test.step("an inline editor blurred under the panel stays blurred", async () => {
|
||||
await composerLocator(page).fill(draft);
|
||||
await composerLocator(page).click();
|
||||
await expectComposerFocused(page);
|
||||
await panel.open(page);
|
||||
await panel.expectOpen(page);
|
||||
await blurComposerInput(page);
|
||||
await panel.close(page);
|
||||
await panel.expectClosed(page);
|
||||
await expectComposerNotFocused(page);
|
||||
await expectComposerDraft(page, draft);
|
||||
});
|
||||
|
||||
await test.step("a fullscreen editor blurred under the panel stays blurred", async () => {
|
||||
await composerLocator(page).click();
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerFocused(page);
|
||||
await panel.open(page);
|
||||
await panel.expectOpen(page);
|
||||
await blurComposerInput(page);
|
||||
await panel.close(page);
|
||||
await panel.expectClosed(page);
|
||||
await expectComposerNotFocused(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerDraft(page, draft);
|
||||
await exitComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function composerHeight(page: Page): Promise<number> {
|
||||
@@ -165,22 +166,12 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
|
||||
}
|
||||
});
|
||||
|
||||
async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error("viewport size is required");
|
||||
const y = Math.round(viewport.height / 2);
|
||||
await page.mouse.move(8, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(40, y, { steps: 4 });
|
||||
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/** Freeze the page's timers and animation frames until `page.clock.resume()`. */
|
||||
async function pausePageClock(page: Page): Promise<void> {
|
||||
const now = Date.now();
|
||||
await page.clock.install({ time: now });
|
||||
await page.clock.pauseAt(now);
|
||||
// The installed clock advances between protocol calls; pause at a future time.
|
||||
await page.clock.pauseAt(now + 60_000);
|
||||
}
|
||||
|
||||
/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */
|
||||
@@ -216,7 +207,7 @@ test("compact long drafts stop growing before the header", async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test("compact fullscreen editing preserves the live draft and selection and stays open after send", async ({
|
||||
test("compact fullscreen editing preserves the live draft and selection and returns to chat after send", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
@@ -270,7 +261,7 @@ test("compact fullscreen editing preserves the live draft and selection and stay
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
});
|
||||
await test.step("sending inside fullscreen clears the editor and keeps fullscreen open", async () => {
|
||||
await test.step("sending inside fullscreen returns to chat and clears the editor", async () => {
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await enterComposerFullscreen(page);
|
||||
await writeCompactDraft(page, "Short draft");
|
||||
@@ -284,10 +275,12 @@ test("compact fullscreen editing preserves the live draft and selection and stay
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
|
||||
});
|
||||
await test.step("typing and sending keep working after leaving fullscreen again", async () => {
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await enterComposerFullscreen(page);
|
||||
await writeCompactDraft(page, "Typed inside fullscreen");
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await exitComposerFullscreen(page);
|
||||
@@ -349,16 +342,16 @@ test("fullscreen submit rejection retains the editable draft and supports retry"
|
||||
await writeCompactDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await sendComposerWithButton(page);
|
||||
await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText(
|
||||
"Requested mock prompt rejection",
|
||||
);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await expect(page.getByRole("alert")).toHaveText("Requested mock prompt rejection");
|
||||
await enterComposerFullscreen(page);
|
||||
await expectFullscreenErrorAboveSend(page);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
@@ -450,8 +443,7 @@ test("fullscreen Send keeps the configured queue behavior while an agent runs",
|
||||
await expectComposerFullscreenControls(page);
|
||||
await queueComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await exitComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await expectQueuedMessageButton(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import {
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
resizeComposerViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
delayBrowserWorkspaceCreatedResponse,
|
||||
expectNewWorkspaceDraft,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
fillNewWorkspaceDraft,
|
||||
@@ -134,3 +141,45 @@ test.describe("New workspace composer draft", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("fullscreen creation returns inline and keeps submission locked when reopened", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
|
||||
const delay = await delayBrowserWorkspaceCreatedResponse(page);
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await fillNewWorkspaceDraft(page, DRAFT);
|
||||
await enterComposerFullscreen(page);
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await delay.waitForCreateRequest();
|
||||
|
||||
await test.step("submission returns inline with the original draft and spinner", async () => {
|
||||
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toHaveValue(DRAFT);
|
||||
await expect(composerLocator(page)).not.toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("progressbar")).toBeVisible();
|
||||
});
|
||||
await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => {
|
||||
await enterComposerFullscreen(page);
|
||||
await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
|
||||
await expect(composerLocator(page)).toHaveValue(DRAFT);
|
||||
await expect(composerLocator(page)).not.toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("progressbar")).toBeVisible();
|
||||
await exitComposerFullscreen(page);
|
||||
});
|
||||
delay.release();
|
||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"));
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
} finally {
|
||||
delay.release();
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,10 +87,11 @@ export async function emitSettledAssistantImage(
|
||||
client: SeedDaemonClient,
|
||||
agent: ArchiveTabAgent,
|
||||
image: AssistantImageFixture,
|
||||
destination: string = image.relativePath,
|
||||
): Promise<void> {
|
||||
await client.sendAgentMessage(
|
||||
agent.id,
|
||||
`Emit settled assistant image Markdown: `,
|
||||
`Emit settled assistant image Markdown: `,
|
||||
);
|
||||
const result = await client.waitForFinish(agent.id, 30_000);
|
||||
if (result.status !== "idle" || result.final?.lastError) {
|
||||
|
||||
@@ -56,6 +56,12 @@ export async function expectComposerNotFocused(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).not.toBeFocused();
|
||||
}
|
||||
|
||||
/** Blur the editor without anything else taking focus. */
|
||||
export async function blurComposerInput(page: Page): Promise<void> {
|
||||
await composerInput(page).blur();
|
||||
await expectComposerNotFocused(page);
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
+21
-20
@@ -1,11 +1,16 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/** The compact workspace sheet, named after the tab it hosts. */
|
||||
export function workspaceSheet(page: Page, label: string): Locator {
|
||||
/** A sheet tab's body, named after the tab it shows. */
|
||||
export function sheet(page: Page, label: string): Locator {
|
||||
return page.getByRole("dialog", { name: label });
|
||||
}
|
||||
|
||||
/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */
|
||||
/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */
|
||||
export function sheetHeader(page: Page, label: string): Locator {
|
||||
return page.getByTestId("sheet-tab-header").filter({ hasText: label });
|
||||
}
|
||||
|
||||
/** Sheet height as a fraction of the viewport; mirrors the sheet tab's 92% snap point. */
|
||||
const SHEET_SNAP_FRACTION = 0.92;
|
||||
/** The header starts below the sheet's drag indicator, inside the handle. */
|
||||
const SHEET_INDICATOR_MAX_HEIGHT = 32;
|
||||
@@ -14,12 +19,11 @@ const SHEET_INDICATOR_MAX_HEIGHT = 32;
|
||||
* The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it
|
||||
* covers the workspace header; a sheet sunk below it hides content.
|
||||
*/
|
||||
export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Promise<void> {
|
||||
export async function expectSheetAtSnap(page: Page, label: string): Promise<void> {
|
||||
const viewportHeight = page.viewportSize()?.height ?? 0;
|
||||
const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
|
||||
const sheet = workspaceSheet(page, label);
|
||||
const header = sheet.getByTestId("workspace-sheet-header");
|
||||
const body = sheet.getByTestId("workspace-sheet-body");
|
||||
const header = sheetHeader(page, label);
|
||||
const body = sheet(page, label);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]);
|
||||
@@ -39,26 +43,22 @@ export async function expectOpenTabCount(page: Page, count: number): Promise<voi
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function closeWorkspaceSheet(page: Page, label: string): Promise<void> {
|
||||
const sheet = workspaceSheet(page, label);
|
||||
await sheet.getByRole("button", { name: "Close" }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
export async function closeSheet(page: Page, label: string): Promise<void> {
|
||||
await sheetHeader(page, label).getByRole("button", { name: "Close" }).click();
|
||||
await expect(sheet(page, label)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function openWorkspaceSheetAsTab(page: Page, label: string): Promise<void> {
|
||||
const sheet = workspaceSheet(page, label);
|
||||
await sheet.getByRole("button", { name: "Open as tab" }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
export async function openSheetAsTab(page: Page, label: string): Promise<void> {
|
||||
await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
|
||||
await expect(sheet(page, label)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function replaceWorkspaceSheetFileText(
|
||||
export async function replaceSheetFileText(
|
||||
page: Page,
|
||||
label: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const editor = workspaceSheet(page, label)
|
||||
.getByTestId("file-source-editor")
|
||||
.locator(".cm-content");
|
||||
const editor = sheet(page, label).getByTestId("file-source-editor").locator(".cm-content");
|
||||
await editor.click();
|
||||
await editor.press("ControlOrMeta+A");
|
||||
await editor.pressSequentially(content);
|
||||
@@ -69,6 +69,7 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
|
||||
.getByRole("button", { name: /Switch tabs/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
|
||||
const option = page.getByText(label, { exact: true }).filter({ visible: true }).last();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
@@ -76,7 +77,7 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
|
||||
}
|
||||
|
||||
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
|
||||
export async function tapWorkspaceSheetBackdrop(page: Page): Promise<void> {
|
||||
export async function tapSheetBackdrop(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||
.last()
|
||||
@@ -250,3 +250,41 @@ export async function expectMobileAgentSidebarVisible(page: Page): Promise<void>
|
||||
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */
|
||||
export async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error("viewport size is required");
|
||||
const y = Math.round(viewport.height / 2);
|
||||
await page.mouse.move(8, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(40, y, { steps: 4 });
|
||||
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/** Drag from the right edge to open the compact Explorer over the agent. */
|
||||
export async function swipeOpenMobileExplorer(page: Page): Promise<void> {
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error("viewport size is required");
|
||||
const y = Math.round(viewport.height / 2);
|
||||
await page.mouse.move(viewport.width - 8, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(viewport.width - 40, y, { steps: 4 });
|
||||
await page.mouse.move(Math.round(viewport.width * 0.3), y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function closeMobileExplorer(page: Page): Promise<void> {
|
||||
const closeButton = page.getByTestId("explorer-close");
|
||||
await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 });
|
||||
await closeButton.click();
|
||||
}
|
||||
|
||||
export async function expectMobileExplorerVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectMobileExplorerHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
@@ -42,6 +42,18 @@ describe("fileUriToPath", () => {
|
||||
});
|
||||
|
||||
describe("localFileSourceToPath", () => {
|
||||
it.each([
|
||||
"https://example.com/image%20name.png",
|
||||
"data:image/png;base64,abc%20",
|
||||
"blob:https://example.com/image%20name",
|
||||
])("preserves a direct Markdown image URI: %s", (source) => {
|
||||
expect(localFileSourceToPath(source, { decodeHref: true })).toBe(source);
|
||||
});
|
||||
|
||||
it("preserves malformed percent escapes rather than throwing", () => {
|
||||
expect(localFileSourceToPath("/tmp/100%.png", { decodeHref: true })).toBe("/tmp/100%.png");
|
||||
});
|
||||
|
||||
it("decodes markdown-encoded Windows drive-letter paths", () => {
|
||||
expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt");
|
||||
});
|
||||
|
||||
@@ -173,11 +173,18 @@ export function fileUriToPath(uri: string): string {
|
||||
return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1"));
|
||||
}
|
||||
|
||||
export function localFileSourceToPath(source: string): string {
|
||||
export function localFileSourceToPath(
|
||||
source: string,
|
||||
// Markdown destinations are URI-encoded; plain filesystem paths are not.
|
||||
{ decodeHref = false }: { decodeHref?: boolean } = {},
|
||||
): string {
|
||||
if (/^(https?:|data:|blob:)/i.test(source)) {
|
||||
return source;
|
||||
}
|
||||
let path = source;
|
||||
if (source.startsWith("file://")) {
|
||||
path = fileUriToPath(source);
|
||||
} else if (isMarkdownEncodedWindowsDrivePath(source)) {
|
||||
} else if (decodeHref || isMarkdownEncodedWindowsDrivePath(source)) {
|
||||
path = decodeFilePathSource(source);
|
||||
}
|
||||
return normalizeWindowsDrivePath(path);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "../lib/overlay-root";
|
||||
import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
SheetVisibleFrame,
|
||||
@@ -287,6 +287,37 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
|
||||
return <View style={[styles.sheetContent, style]}>{children}</View>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An icon action in a sheet header's trailing rail, drawn like the Close button so a header's
|
||||
* actions line up with it at one size. Pass it through `SheetHeader.actions`.
|
||||
*/
|
||||
export function SheetHeaderIconButton({
|
||||
icon: Icon,
|
||||
accessibilityLabel,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
accessibilityLabel: string;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
style={styles.closeButton}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<Icon size={16} color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function SheetHeaderView({
|
||||
header,
|
||||
onClose,
|
||||
@@ -343,19 +374,11 @@ export function SheetHeaderView({
|
||||
</View>
|
||||
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
|
||||
{showCloseButton ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
<SheetHeaderIconButton
|
||||
icon={X}
|
||||
accessibilityLabel={t("common.actions.close")}
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<X
|
||||
size={16}
|
||||
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{search ? (
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
} from "@/assistant-file-links";
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
|
||||
import { localFileSourceToPath } from "@/attachments/utils";
|
||||
import {
|
||||
AttachmentFrame,
|
||||
AttachmentLabel,
|
||||
@@ -1945,7 +1946,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
return (
|
||||
<AssistantMarkdownImage
|
||||
key={node.key}
|
||||
source={String(node.attributes?.src ?? "")}
|
||||
source={localFileSourceToPath(String(node.attributes?.src ?? ""), { decodeHref: true })}
|
||||
occurrenceKey={`${occurrenceKey}:${node.key}`}
|
||||
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
|
||||
hasLeadingContent={hasLeadingContent}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
BottomSheetModalInternalProvider,
|
||||
INITIAL_CONTAINER_LAYOUT,
|
||||
useBottomSheetModalInternal,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { PortalHost } from "@gorhom/portal";
|
||||
import { useCallback, useId, useMemo, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
|
||||
/**
|
||||
* Hosts the sheets presented from inside it.
|
||||
*
|
||||
* A sheet's content normally lands at the app root, on the far side of the portal, where every
|
||||
* context between the root and the call site is gone (floating-panels.md Gotcha 7). Inside a
|
||||
* scope, a sheet renders into the scope's own host instead, so its content reads the scope's
|
||||
* contexts — the navigator, the workspace providers — without bridging each one by hand. The
|
||||
* scope only moves where the sheet draws: it still joins the app-wide sheet stack, so stacking,
|
||||
* Back and the backdrop treat it like every other sheet.
|
||||
*
|
||||
* The host fills the scope, and the sheets presented in it size and dock to the scope, not the
|
||||
* window. Sheets presented outside the scope keep drawing at the root, above it.
|
||||
*/
|
||||
export function SheetHostScope({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const parent = useBottomSheetModalInternal();
|
||||
const hostName = `sheet-host-${useId()}`;
|
||||
const containerLayoutState = useSharedValue({ ...INITIAL_CONTAINER_LAYOUT });
|
||||
const hostRef = useRef<View>(null);
|
||||
|
||||
// Mirrors Gorhom's own hosting container measurement, so a sheet in the scope lays out against
|
||||
// the scope the way a root sheet lays out against the window.
|
||||
const handleLayout = useCallback(
|
||||
({ nativeEvent }: LayoutChangeEvent) => {
|
||||
const { height } = nativeEvent.layout;
|
||||
hostRef.current?.measure((_x, _y, _width, _height, _pageX, pageY) => {
|
||||
const windowHeight = Dimensions.get("window").height;
|
||||
const top = pageY ?? 0;
|
||||
containerLayoutState.set({
|
||||
height,
|
||||
offset: {
|
||||
top,
|
||||
bottom: Math.max(0, windowHeight - (top + height + (StatusBar.currentHeight ?? 0))),
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
[containerLayoutState],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ ...parent, hostName, containerLayoutState }),
|
||||
[containerLayoutState, hostName, parent],
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModalInternalProvider value={value}>
|
||||
<View style={[styles.scope, style]}>
|
||||
{children}
|
||||
<View ref={hostRef} style={styles.host} pointerEvents="box-none" onLayout={handleLayout}>
|
||||
<PortalHost name={hostName} />
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetModalInternalProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scope: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
host: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
});
|
||||
@@ -40,6 +40,13 @@ type IsolatedBottomSheetModalProps = Omit<
|
||||
BottomSheetModalProps,
|
||||
"enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent"
|
||||
> & {
|
||||
/**
|
||||
* Whether closing (drag, backdrop, Back) also dismisses, which unmounts the content. Default
|
||||
* true: the owner hears `onDismiss` and drops its state. Pass `false` for a sheet whose close the
|
||||
* owner may veto: it parks at index -1 with its content mounted, the owner hears `onChange(-1)`,
|
||||
* and either snaps it back or unmounts it, which dismisses it from the stack.
|
||||
*/
|
||||
enableDismissOnClose?: boolean;
|
||||
/**
|
||||
* Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge
|
||||
* would have to reach around it.
|
||||
@@ -84,19 +91,24 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
presentation = "push",
|
||||
contextBridge,
|
||||
backdropOpacity,
|
||||
enableDismissOnClose = true,
|
||||
...bottomSheetProps
|
||||
} = props;
|
||||
const renderBackdrop = useCallback(
|
||||
(backdropProps: BottomSheetBackdropProps) => (
|
||||
<SheetBackdrop {...backdropProps} opacity={backdropOpacity} />
|
||||
<SheetBackdrop
|
||||
{...backdropProps}
|
||||
opacity={backdropOpacity}
|
||||
pressBehavior={enableDismissOnClose ? "dismiss" : "close"}
|
||||
/>
|
||||
),
|
||||
[backdropOpacity],
|
||||
[backdropOpacity, enableDismissOnClose],
|
||||
);
|
||||
const modal = (
|
||||
<GorhomBottomSheetModal
|
||||
{...bottomSheetProps}
|
||||
ref={modalRef}
|
||||
enableDismissOnClose
|
||||
enableDismissOnClose={enableDismissOnClose}
|
||||
stackBehavior={presentation}
|
||||
backdropComponent={backdropOpacity === undefined ? undefined : renderBackdrop}
|
||||
>
|
||||
@@ -155,3 +167,4 @@ export function useIsolatedBottomSheetVisibility({
|
||||
|
||||
export { SheetVisibleFrame } from "./visible-frame";
|
||||
export { systemBackPress } from "./back-press";
|
||||
export { SheetHostScope } from "./host-scope";
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet";
|
||||
|
||||
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
|
||||
/**
|
||||
* What a backdrop tap does. `dismiss` takes the top modal off the stack and unmounts it; `close`
|
||||
* only slides this sheet down, so a sheet that keeps its content through a close can veto it.
|
||||
*/
|
||||
export type SheetBackdropPress = "dismiss" | "close";
|
||||
|
||||
export function SheetBackdrop({
|
||||
pressBehavior: _pressBehavior,
|
||||
...props
|
||||
}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) {
|
||||
// Gorhom's native backdrop closes its own sheet, which here is always the top one.
|
||||
return <BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
useBottomSheet,
|
||||
useBottomSheetModal,
|
||||
type BottomSheetBackdropProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, StyleSheet, View } from "react-native";
|
||||
import type { SheetBackdropPress } from "./sheet-backdrop";
|
||||
|
||||
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
|
||||
export type { SheetBackdropPress };
|
||||
|
||||
export function SheetBackdrop({
|
||||
pressBehavior,
|
||||
...props
|
||||
}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) {
|
||||
const { dismiss } = useBottomSheetModal();
|
||||
const { close } = useBottomSheet();
|
||||
const { t } = useTranslation();
|
||||
const dismissTopSheet = useCallback(() => dismiss(), [dismiss]);
|
||||
const handlePress = useCallback(() => {
|
||||
// A sheet that may veto its close slides itself down and lets its owner decide. The other
|
||||
// sheets ask the provider for the top modal, so a lower backdrop caught mid-transition still
|
||||
// dismisses the sheet the user sees.
|
||||
if (pressBehavior === "close") close();
|
||||
else dismiss();
|
||||
}, [close, dismiss, pressBehavior]);
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
@@ -28,7 +42,7 @@ export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: numb
|
||||
accessibilityLabel={t("common.bottomSheetBackdrop")}
|
||||
// The provider owns presentation order, including when a lower backdrop remounts.
|
||||
// Keep this target until the portal unmounts: animation must not expose the sheet below.
|
||||
onPress={dismissTopSheet}
|
||||
onPress={handlePress}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -126,8 +126,14 @@ export interface MenuSurfaceProps {
|
||||
* popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time.
|
||||
*/
|
||||
export function MenuSurface(props: MenuSurfaceProps): ReactElement | null {
|
||||
const { presentation } = useMenuContext("MenuSurface");
|
||||
const { presentation, open, setOpen } = useMenuContext("MenuSurface");
|
||||
const active = useRetainedPanelActive();
|
||||
// A menu belongs to the panel that opened it. When that panel leaves the screen the menu closes
|
||||
// for good, rather than waiting hidden to reappear with the panel: a row that opened a sheet
|
||||
// and promoted it to a tab, say, would otherwise bring the list back on the next tab switch.
|
||||
useEffect(() => {
|
||||
if (!active && open) setOpen(false);
|
||||
}, [active, open, setOpen]);
|
||||
// Portals escape the hidden panel. Remove the surface in its inactive commit,
|
||||
// before freezing can suspend an async menu action's closing update.
|
||||
if (!active) return null;
|
||||
|
||||
@@ -1196,11 +1196,16 @@ export function Composer({ isPaneFocused, ...props }: ComposerProps) {
|
||||
// A compact overlay panel (agent list, explorer) covers the pane without taking its
|
||||
// focus. The composer gives up input from the moment the user asks for the panel,
|
||||
// not when its motion settles, so the exit action and autocomplete never race it.
|
||||
// Pane focus stays put, so the keyboard the panel dismisses is a blur in the
|
||||
// user's own pane: closing the panel does not bring focus back.
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent");
|
||||
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
|
||||
return (
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused && !coveredByOverlayPanel}>
|
||||
<ComposerKeyboardScopeProvider
|
||||
isPaneFocused={isPaneFocused}
|
||||
isActiveComposer={isPaneFocused && !coveredByOverlayPanel}
|
||||
>
|
||||
<RenderProfile id="ComposerContent">
|
||||
<ComposerContent {...props} />
|
||||
</RenderProfile>
|
||||
|
||||
@@ -524,8 +524,8 @@ function MessageInputAutoFocus({
|
||||
autoFocusKey: string | undefined;
|
||||
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>;
|
||||
}) {
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey);
|
||||
const { isPaneFocused } = useComposerKeyboardScope();
|
||||
useAutoFocusOnWebEffect(textInputRef, enabled && isPaneFocused, autoFocusKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -942,6 +942,7 @@ interface SendMessageContext {
|
||||
onSubmit: (payload: MessagePayload) => void;
|
||||
onMinimizeHeight: () => void;
|
||||
preserveHeightOnSubmit: boolean;
|
||||
exitFullscreen: () => void;
|
||||
}
|
||||
|
||||
function sendMessageImpl(ctx: SendMessageContext): void {
|
||||
@@ -954,6 +955,7 @@ function sendMessageImpl(ctx: SendMessageContext): void {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ctx.exitFullscreen();
|
||||
ctx.onSubmit({
|
||||
text: trimmed,
|
||||
attachments: ctx.attachments,
|
||||
@@ -974,12 +976,14 @@ interface QueueMessageContext {
|
||||
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||
replaceText: (text: string) => void;
|
||||
onMinimizeHeight: () => void;
|
||||
exitFullscreen: () => void;
|
||||
}
|
||||
|
||||
function queueMessageImpl(ctx: QueueMessageContext): void {
|
||||
if (!ctx.onQueue) return;
|
||||
const trimmed = ctx.value.trim();
|
||||
if (!trimmed && ctx.attachments.length === 0) return;
|
||||
ctx.exitFullscreen();
|
||||
ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd });
|
||||
ctx.replaceText("");
|
||||
ctx.onMinimizeHeight();
|
||||
@@ -1236,7 +1240,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
textReplacement,
|
||||
submitLabel,
|
||||
} = resolveMessageInputProps(props);
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const { isPaneFocused } = useComposerKeyboardScope();
|
||||
const mode = resolveComposerInputMode(inputMode);
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -1264,8 +1268,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const focusIntentRef = useRef(false);
|
||||
const valueRef = useRef(value);
|
||||
const selectionRef = useRef({ start: value.length, end: value.length });
|
||||
const activeComposerRef = useRef(isActiveComposer);
|
||||
activeComposerRef.current = isActiveComposer;
|
||||
const paneFocusedRef = useRef(isPaneFocused);
|
||||
paneFocusedRef.current = isPaneFocused;
|
||||
const [webTextArea, setWebTextArea] = useState<HTMLElement | null>(null);
|
||||
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
|
||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||
@@ -1285,9 +1289,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
webTextareaRef.current = element;
|
||||
setWebTextArea(element);
|
||||
}, []);
|
||||
// Focus intent outlives a blur only while the pane is not the user's pane (a retained
|
||||
// tab hidden, a sheet over it); the pane coming back restores focus. A blur while the
|
||||
// pane is theirs drops it, whatever the layout mode: the user dismissed the keyboard,
|
||||
// or a compact overlay panel did on their behalf, and nothing later refocuses.
|
||||
useLayoutEffect(() => {
|
||||
if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus();
|
||||
}, [isActiveComposer]);
|
||||
if (isPaneFocused && focusIntentRef.current) textInputRef.current?.focus();
|
||||
}, [isPaneFocused]);
|
||||
// The compact and desktop screens mount the editor in different trees, so a
|
||||
// viewport that crosses the breakpoint remounts it. The draft owner brings the
|
||||
// text back; the session brings the caret and focus intent back. On web the
|
||||
@@ -1305,7 +1313,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
textInputRef.current?.setSelection(selection);
|
||||
if (retained.focused) {
|
||||
focusIntentRef.current = true;
|
||||
if (activeComposerRef.current) textInputRef.current?.focus();
|
||||
if (paneFocusedRef.current) textInputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
@@ -1636,6 +1644,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onSubmit,
|
||||
onMinimizeHeight: minimizeInputHeight,
|
||||
preserveHeightOnSubmit,
|
||||
exitFullscreen,
|
||||
});
|
||||
}, [
|
||||
allowEmptySubmit,
|
||||
@@ -1647,6 +1656,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
minimizeInputHeight,
|
||||
preserveHeightOnSubmit,
|
||||
updateLiveTextPresence,
|
||||
exitFullscreen,
|
||||
]);
|
||||
|
||||
const handleQueueMessage = useCallback(
|
||||
@@ -1658,8 +1668,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onQueue,
|
||||
replaceText,
|
||||
onMinimizeHeight: minimizeInputHeight,
|
||||
exitFullscreen,
|
||||
}),
|
||||
[attachments, cwd, onQueue, replaceText, minimizeInputHeight],
|
||||
[attachments, cwd, onQueue, replaceText, minimizeInputHeight, exitFullscreen],
|
||||
);
|
||||
|
||||
const handleDefaultSendAction = useCallback(() => {
|
||||
@@ -1807,13 +1818,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
}, [onFocusChange]);
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
// Fullscreen is nothing but the editor, so a blur there (a retained tab being
|
||||
// hidden, the keyboard dismissed) keeps the intent: showing it again refocuses.
|
||||
if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false;
|
||||
if (paneFocusedRef.current) focusIntentRef.current = false;
|
||||
isInputFocusedRef.current = false;
|
||||
setIsInputFocused(false);
|
||||
onFocusChange?.(false);
|
||||
}, [isFullscreen, onFocusChange]);
|
||||
}, [onFocusChange]);
|
||||
|
||||
const handlePasteError = useCallback(
|
||||
(message: string) => {
|
||||
|
||||
@@ -7,18 +7,35 @@ import {
|
||||
} from "react";
|
||||
|
||||
interface ComposerKeyboardScopeValue {
|
||||
/**
|
||||
* The pane this composer sits in is the user's pane. Focus restoration follows it:
|
||||
* the editor keeps its focus intent across blurs that happen while the pane is not
|
||||
* the user's (a retained tab hidden, a sheet over it) and brings focus back when
|
||||
* the pane is theirs again.
|
||||
*/
|
||||
isPaneFocused: boolean;
|
||||
/**
|
||||
* The composer takes input right now: keyboard actions, shortcuts, autocomplete,
|
||||
* and the fullscreen exit action. False while a compact overlay panel covers the
|
||||
* pane, which does not take pane focus.
|
||||
*/
|
||||
isActiveComposer: boolean;
|
||||
}
|
||||
|
||||
const ComposerKeyboardScopeContext = createContext<ComposerKeyboardScopeValue>({
|
||||
isPaneFocused: false,
|
||||
isActiveComposer: false,
|
||||
});
|
||||
|
||||
export function ComposerKeyboardScopeProvider({
|
||||
isPaneFocused,
|
||||
isActiveComposer,
|
||||
children,
|
||||
}: PropsWithChildren<ComposerKeyboardScopeValue>): ReactElement {
|
||||
const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]);
|
||||
const value = useMemo(
|
||||
() => ({ isPaneFocused, isActiveComposer }),
|
||||
[isPaneFocused, isActiveComposer],
|
||||
);
|
||||
return (
|
||||
<ComposerKeyboardScopeContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { act, type ReactNode } from "react";
|
||||
import React, { act, useCallback, useState, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks";
|
||||
|
||||
const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }];
|
||||
@@ -109,6 +110,43 @@ describe("composer track panel", () => {
|
||||
expect(row("row")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("closes for good when the panel it belongs to leaves the screen", () => {
|
||||
const onPress = vi.fn();
|
||||
function Harness() {
|
||||
const [active, setActive] = useState(true);
|
||||
const hide = useCallback(() => setActive(false), []);
|
||||
const show = useCallback(() => setActive(true), []);
|
||||
return (
|
||||
<>
|
||||
<Pressable accessibilityRole="button" testID="hide" onPress={hide} />
|
||||
<Pressable accessibilityRole="button" testID="show" onPress={show} />
|
||||
<RetainedPanelActivity active={active}>
|
||||
<ComposerTrackPill testID="pill" segments={SUBAGENT_SEGMENTS} panelTitle="Subagents">
|
||||
<ComposerTrackRow
|
||||
accessibilityLabel="Subagent one"
|
||||
testID="row"
|
||||
closeOnSelect={false}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text>Subagent one</Text>
|
||||
</ComposerTrackRow>
|
||||
</ComposerTrackPill>
|
||||
</RetainedPanelActivity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
mount(<Harness />);
|
||||
|
||||
openPanel();
|
||||
expect(isPanelOpen()).toBe(true);
|
||||
|
||||
click(row("hide") as Element);
|
||||
click(row("show") as Element);
|
||||
|
||||
expect(isPanelOpen()).toBe(false);
|
||||
expect(row("row")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves the panel alone when a row's own action button is pressed", () => {
|
||||
const onPress = vi.fn();
|
||||
const onAction = vi.fn();
|
||||
|
||||
@@ -631,7 +631,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "تبديل علامات التبويب (فتح{{count}})",
|
||||
title: "علامة التبويب التبديل",
|
||||
title: "علامات تبويب الـ Workspace",
|
||||
searchPlaceholder: "علامات تبويب البحث",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -628,7 +628,7 @@ export const en = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "Switch tabs ({{count}} open)",
|
||||
title: "Switch tab",
|
||||
title: "Workspace tabs",
|
||||
searchPlaceholder: "Search tabs",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -637,7 +637,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "Cambiar pestañas ({{count}}abierto)",
|
||||
title: "Cambiar pestaña",
|
||||
title: "Pestañas del workspace",
|
||||
searchPlaceholder: "Pestañas de búsqueda",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -636,7 +636,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "Changer d’onglet ({{count}} ouverts)",
|
||||
title: "Changer d’onglet",
|
||||
title: "Onglets de l’espace de travail",
|
||||
searchPlaceholder: "Rechercher des onglets",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -637,7 +637,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "タブを切り替え({{count}}件開いています)",
|
||||
title: "タブを切り替え",
|
||||
title: "ワークスペースのタブ",
|
||||
searchPlaceholder: "タブを検索",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -632,7 +632,7 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "탭 전환 ({{count}}개 열림)",
|
||||
title: "탭 전환",
|
||||
title: "워크스페이스 탭",
|
||||
searchPlaceholder: "탭 검색",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -636,7 +636,7 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "Alternar abas ({{count}} abertas)",
|
||||
title: "Alternar aba",
|
||||
title: "Abas do workspace",
|
||||
searchPlaceholder: "Buscar abas",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -638,7 +638,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "Переключить вкладки (открыто: {{count}})",
|
||||
title: "Переключить вкладку",
|
||||
title: "Вкладки рабочего пространства",
|
||||
searchPlaceholder: "Поиск вкладок",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -631,7 +631,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "切换标签(已打开 {{count}} 个)",
|
||||
title: "切换标签",
|
||||
title: "Workspace 标签",
|
||||
searchPlaceholder: "搜索标签",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentFactsLine, type AgentFactsInput } from "./agent-facts-line";
|
||||
|
||||
const models = [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-fable-5-1",
|
||||
label: "Fable 5.1",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const modes = [
|
||||
{ id: "default", label: "Default" },
|
||||
{ id: "bypassPermissions", label: "Bypass permissions" },
|
||||
];
|
||||
|
||||
const base: AgentFactsInput = {
|
||||
models,
|
||||
runtimeModelId: "claude-fable-5-1",
|
||||
configuredModelId: null,
|
||||
runtimeThinkingOptionId: "high",
|
||||
explicitThinkingOptionId: null,
|
||||
availableModes: modes,
|
||||
currentModeId: "bypassPermissions",
|
||||
contextWindowUsedTokens: 56_200,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
};
|
||||
|
||||
describe("buildAgentFactsLine", () => {
|
||||
it("joins model, thinking, mode, and context usage as the controls show them", () => {
|
||||
expect(buildAgentFactsLine(base)).toBe(
|
||||
"Fable 5.1 · High · Bypass permissions · 56k / 200k tokens (28%)",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the raw model id before the catalog has loaded", () => {
|
||||
expect(
|
||||
buildAgentFactsLine({
|
||||
...base,
|
||||
models: null,
|
||||
runtimeModelId: null,
|
||||
configuredModelId: "gpt-6.1-sol",
|
||||
runtimeThinkingOptionId: null,
|
||||
explicitThinkingOptionId: "medium",
|
||||
availableModes: [],
|
||||
contextWindowUsedTokens: null,
|
||||
contextWindowMaxTokens: null,
|
||||
}),
|
||||
).toBe("gpt-6.1-sol · Medium");
|
||||
});
|
||||
|
||||
it("falls back to the provider's first mode until the agent reports one", () => {
|
||||
expect(
|
||||
buildAgentFactsLine({
|
||||
...base,
|
||||
currentModeId: null,
|
||||
contextWindowUsedTokens: undefined,
|
||||
contextWindowMaxTokens: undefined,
|
||||
}),
|
||||
).toBe("Fable 5.1 · High · Default");
|
||||
});
|
||||
|
||||
it("leaves thinking and context out when the agent has none", () => {
|
||||
expect(
|
||||
buildAgentFactsLine({
|
||||
...base,
|
||||
models: [{ provider: "mock", id: "m", label: "Model M" }],
|
||||
runtimeModelId: "m",
|
||||
runtimeThinkingOptionId: null,
|
||||
availableModes: [],
|
||||
contextWindowUsedTokens: 10,
|
||||
contextWindowMaxTokens: 0,
|
||||
}),
|
||||
).toBe("Model M");
|
||||
});
|
||||
|
||||
it("is empty until the agent has a model", () => {
|
||||
expect(
|
||||
buildAgentFactsLine({ ...base, runtimeModelId: null, configuredModelId: " " }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { AgentMode, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import { formatAgentModeLabel } from "@/agent-controls/labels";
|
||||
import { formatTokenCount } from "@/components/context-window-meter.utils";
|
||||
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
|
||||
|
||||
export interface AgentFactsInput {
|
||||
models: AgentModelDefinition[] | null;
|
||||
runtimeModelId: string | null | undefined;
|
||||
configuredModelId: string | null | undefined;
|
||||
runtimeThinkingOptionId: string | null | undefined;
|
||||
explicitThinkingOptionId: string | null | undefined;
|
||||
availableModes: AgentMode[];
|
||||
currentModeId: string | null | undefined;
|
||||
contextWindowUsedTokens: number | null | undefined;
|
||||
contextWindowMaxTokens: number | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the composer's controls and context meter show about a Paseo agent, as one line: model,
|
||||
* thinking, mode, and context usage, each resolved exactly as its control resolves it. Null until
|
||||
* the agent has a model at all.
|
||||
*/
|
||||
export function buildAgentFactsLine(input: AgentFactsInput): string | null {
|
||||
const hasModel = Boolean(input.runtimeModelId?.trim() || input.configuredModelId?.trim());
|
||||
if (!hasModel) return null;
|
||||
const selection = resolveAgentModelSelection(input);
|
||||
const parts = [selection.displayModel];
|
||||
if (selection.selectedThinkingId) parts.push(selection.displayThinking);
|
||||
const mode = resolveModeLabel(input.availableModes, input.currentModeId);
|
||||
if (mode) parts.push(mode);
|
||||
const context = formatContextUsage(input.contextWindowUsedTokens, input.contextWindowMaxTokens);
|
||||
if (context) parts.push(context);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** The mode control shows the current mode, or the provider's first one until the agent reports. */
|
||||
function resolveModeLabel(modes: AgentMode[], currentModeId: string | null | undefined) {
|
||||
const mode = modes.find((candidate) => candidate.id === currentModeId) ?? modes[0];
|
||||
return mode ? formatAgentModeLabel(mode) : null;
|
||||
}
|
||||
|
||||
/** The context meter's numbers: used of max, with the share it draws as the ring. */
|
||||
function formatContextUsage(
|
||||
usedTokens: number | null | undefined,
|
||||
maxTokens: number | null | undefined,
|
||||
): string | null {
|
||||
if (
|
||||
typeof usedTokens !== "number" ||
|
||||
typeof maxTokens !== "number" ||
|
||||
!Number.isFinite(usedTokens) ||
|
||||
!Number.isFinite(maxTokens) ||
|
||||
usedTokens < 0 ||
|
||||
maxTokens <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const percentage = Math.round(Math.min(100, (usedTokens / maxTokens) * 100));
|
||||
return `${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens (${percentage}%)`;
|
||||
}
|
||||
@@ -60,6 +60,9 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
|
||||
import { TimelineSyncStatus } from "@/timeline/sync-status";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import { buildAgentFactsLine } from "@/panels/agent-facts-line";
|
||||
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useHasPluginComposerPills } from "@/plugins";
|
||||
@@ -1247,6 +1250,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
) : null;
|
||||
const streamContent = (
|
||||
<View style={animatedStaticStyles.content}>
|
||||
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
|
||||
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
||||
<AgentStreamSection
|
||||
streamViewRef={streamViewRef}
|
||||
@@ -1375,6 +1379,36 @@ function TimelineSyncErrorCallout({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the composer's controls and context meter would show, as one line, for an agent shown
|
||||
* without its composer.
|
||||
*/
|
||||
function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: string }) {
|
||||
const agent = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const current = state.sessions[serverId]?.agents.get(agentId);
|
||||
return current
|
||||
? {
|
||||
provider: current.provider,
|
||||
cwd: current.cwd,
|
||||
runtimeModelId: current.runtimeInfo?.model ?? null,
|
||||
configuredModelId: current.model ?? null,
|
||||
runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null,
|
||||
explicitThinkingOptionId: current.thinkingOptionId ?? null,
|
||||
availableModes: current.availableModes,
|
||||
currentModeId: current.currentModeId,
|
||||
contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
);
|
||||
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
|
||||
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
|
||||
const text = agent ? buildAgentFactsLine({ ...agent, models }) : null;
|
||||
return text ? <PaneSubtitle text={text} testID="agent-pane-subtitle" /> : null;
|
||||
}
|
||||
|
||||
const AgentStreamSection = memo(function AgentStreamSection({
|
||||
streamViewRef,
|
||||
serverId,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ArchiveFinishedStatus,
|
||||
useArchiveSubagent,
|
||||
useDetachSubagent,
|
||||
useSubagentOpensInSheet,
|
||||
type SubagentRow,
|
||||
} from "@/subagents";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
@@ -90,6 +91,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
},
|
||||
[openSubagentTarget],
|
||||
);
|
||||
const opensInSheet = useSubagentOpensInSheet(workspaceKey);
|
||||
const handleOpenChanges = useCallback(() => {
|
||||
if (!workspaceKey) {
|
||||
return;
|
||||
@@ -122,6 +124,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
opensInSheet={opensInSheet}
|
||||
onArchiveSubagent={archiveSubagent}
|
||||
onArchiveFinished={onArchiveFinished}
|
||||
archiveFinishedStatus={archiveFinishedStatus}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
/**
|
||||
* One muted line of facts about a pane's agent, under the pane's header and above its stream:
|
||||
* model, thinking, tokens. Provider subagents get it from the daemon; a Paseo agent shown without
|
||||
* its composer gets it from the same facts the composer's controls show.
|
||||
*/
|
||||
export function PaneSubtitle({ text, testID }: { text: string; testID?: string }) {
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.text} numberOfLines={1} testID={testID}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -13,9 +13,11 @@ import { ComposerTrackBar } from "@/composer/tracks";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSubagentsForParent } from "@/subagents/select";
|
||||
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
|
||||
import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import {
|
||||
providerSubagentKey,
|
||||
@@ -30,6 +32,7 @@ import type { StreamItem } from "@/types/stream";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
import type { TurnPresentation } from "@/timeline/turn-liveness";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
const EMPTY_PERMISSIONS = new Map<string, PendingPermission>();
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
@@ -47,10 +50,12 @@ function ProviderSubagentChildTrack({
|
||||
serverId,
|
||||
rows,
|
||||
onOpenProviderSubagent,
|
||||
opensInSheet,
|
||||
}: {
|
||||
serverId: string;
|
||||
rows: ReturnType<typeof useSubagentsForParent>;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
opensInSheet: (row: SubagentRow) => boolean;
|
||||
}) {
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
@@ -60,6 +65,7 @@ function ProviderSubagentChildTrack({
|
||||
rows={rows}
|
||||
onOpenSubagent={NOOP_SUBAGENT}
|
||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||
opensInSheet={opensInSheet}
|
||||
onArchiveSubagent={NOOP_SUBAGENT}
|
||||
/>
|
||||
</ComposerTrackBar>
|
||||
@@ -109,7 +115,7 @@ function useProviderSubagentDescriptor(
|
||||
|
||||
function ProviderSubagentPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, target, openFileInWorkspace, openTab } = usePaneContext();
|
||||
const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext();
|
||||
invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target");
|
||||
const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId);
|
||||
const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`;
|
||||
@@ -137,6 +143,9 @@ function ProviderSubagentPanel() {
|
||||
providerParentSubagentId: target.subagentId,
|
||||
});
|
||||
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
|
||||
const opensInSheet = useSubagentOpensInSheet(
|
||||
buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
);
|
||||
const openProviderChild = useCallback(
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
@@ -243,17 +252,7 @@ function ProviderSubagentPanel() {
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="provider-subagent-panel">
|
||||
{subtitle ? (
|
||||
<View style={styles.subtitleHeader}>
|
||||
<Text
|
||||
style={styles.subtitleText}
|
||||
numberOfLines={1}
|
||||
testID="provider-subagent-pane-subtitle"
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{subtitle ? <PaneSubtitle text={subtitle} testID="provider-subagent-pane-subtitle" /> : null}
|
||||
<AgentStreamView
|
||||
agentId={streamId}
|
||||
serverId={serverId}
|
||||
@@ -273,6 +272,7 @@ function ProviderSubagentPanel() {
|
||||
serverId={serverId}
|
||||
rows={childRows}
|
||||
onOpenProviderSubagent={openProviderChild}
|
||||
opensInSheet={opensInSheet}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -280,16 +280,6 @@ function ProviderSubagentPanel() {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { flex: 1, minHeight: 0 },
|
||||
subtitleHeader: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
subtitleText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import {
|
||||
type ComponentType,
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useStore } from "zustand";
|
||||
import { createStore } from "zustand/vanilla";
|
||||
import { Maximize2 } from "lucide-react-native";
|
||||
import {
|
||||
SheetBackground,
|
||||
SheetHandle,
|
||||
SheetHeaderIconButton,
|
||||
SheetHeaderView,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
type IsolatedBottomSheetModalRef,
|
||||
systemBackPress,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
WorkspacePaneContent,
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
|
||||
|
||||
const SNAP_POINTS = ["92%"];
|
||||
const NO_SHEETS: readonly SheetTab[] = [];
|
||||
|
||||
function PanelIcon({
|
||||
icon: Icon,
|
||||
size,
|
||||
color,
|
||||
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
interface SheetTabHandleValue {
|
||||
header: SheetHeader;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet's handle, showing its header. Gorhom takes `handleComponent` as a component type and
|
||||
* renders it on the far side of the portal: a new function per render would remount the header on
|
||||
* every change, and no context from here reaches it. So each sheet gets one handle component for
|
||||
* its lifetime, which reads the header through a store the sheet keeps current.
|
||||
*/
|
||||
function useSheetTabHandle(value: SheetTabHandleValue): FC<BottomSheetHandleProps> {
|
||||
const [store] = useState(() => createStore<SheetTabHandleValue>(() => value));
|
||||
useEffect(() => store.setState(value, true), [store, value]);
|
||||
const [Handle] = useState(
|
||||
() =>
|
||||
function SheetTabHandle(props: BottomSheetHandleProps) {
|
||||
const { header, onClose } = useStore(store);
|
||||
return (
|
||||
<SheetHandle {...props}>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID="sheet-tab-header" />
|
||||
</SheetHandle>
|
||||
);
|
||||
},
|
||||
);
|
||||
return Handle;
|
||||
}
|
||||
|
||||
export interface SheetTabsProps {
|
||||
workspaceKey: string | null;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
isWorkspaceFocused: boolean;
|
||||
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
|
||||
/** The workspace's ordinary tab close, including its unsaved-changes guard. */
|
||||
onCloseTab: (tabId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface SheetTabSheetProps extends Omit<SheetTabsProps, "workspaceKey" | "onCloseTab"> {
|
||||
sheet: SheetTab;
|
||||
/** Only the top sheet's pane is focused and answers Android Back; the ones below wait their turn. */
|
||||
isTop: boolean;
|
||||
/** Runs the guarded close and resolves to whether the tab is still open. */
|
||||
onClose: (tabId: string) => Promise<boolean>;
|
||||
onOpenAsTab: (tabId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace's sheet tabs, one sheet each, on the app-wide sheet stack. They present from inside
|
||||
* the workspace's `SheetHostScope`, so each panel keeps every context an ordinary pane has, and a
|
||||
* sheet opened from any other sheet (a subagent chosen from the subagents list, say) lands on top of
|
||||
* it and uncovers it on close. The shared sheet parts (`SheetBackground`, `SheetHandle`,
|
||||
* `SheetHeaderView`) keep them looking and dragging like every other sheet.
|
||||
*/
|
||||
export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) {
|
||||
const sheets = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
|
||||
);
|
||||
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
|
||||
const handleOpenAsTab = useCallback(
|
||||
(tabId: string) => {
|
||||
if (workspaceKey) openSheetAsTab(workspaceKey, tabId);
|
||||
},
|
||||
[openSheetAsTab, workspaceKey],
|
||||
);
|
||||
const handleClose = useCallback(
|
||||
async (tabId: string) => {
|
||||
await onCloseTab(tabId);
|
||||
// A declined unsaved-changes prompt leaves the tab in its sheet.
|
||||
const open = workspaceKey
|
||||
? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]
|
||||
: undefined;
|
||||
return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false;
|
||||
},
|
||||
[onCloseTab, workspaceKey],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{sheets.map((sheet, index) => (
|
||||
<SheetTabSheet
|
||||
// Keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
|
||||
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
|
||||
{...rest}
|
||||
sheet={sheet}
|
||||
isTop={index === sheets.length - 1}
|
||||
onClose={handleClose}
|
||||
onOpenAsTab={handleOpenAsTab}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTabSheet({
|
||||
sheet,
|
||||
isTop,
|
||||
serverId,
|
||||
workspaceId,
|
||||
isWorkspaceFocused,
|
||||
buildContent,
|
||||
onClose,
|
||||
onOpenAsTab,
|
||||
}: SheetTabSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { tab } = sheet;
|
||||
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
|
||||
const isScreenFocused = useIsFocused();
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.target.kind);
|
||||
if (!registration) {
|
||||
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
|
||||
}
|
||||
const descriptor = registration.useDescriptor(tab.target as never, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId: tab.tabId,
|
||||
});
|
||||
|
||||
// The sheet lives as long as its store entry: it presents on mount, and unmounting takes it off
|
||||
// the stack, which uncovers the sheet below. Gorhom slides an unmounting sheet down and still
|
||||
// reports that as `onChange(-1)`, which must not read as the user closing it: the store already
|
||||
// moved on, and the same tab id may now be a pane tab (Open as tab).
|
||||
const unmounted = useRef(false);
|
||||
useEffect(() => {
|
||||
sheetRef.current?.present();
|
||||
return () => {
|
||||
unmounted.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Every close path (drag, backdrop, Close, Back) slides the sheet down first; `onChange(-1)` then
|
||||
// runs the one guarded close. The sheet keeps its content through the close, so a declined
|
||||
// unsaved-changes prompt only has to bring it back up.
|
||||
const close = useCallback(() => sheetRef.current?.close(), []);
|
||||
const handleChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index !== -1 || unmounted.current) return;
|
||||
void onClose(tab.tabId).then((stillOpen) => {
|
||||
if (stillOpen) sheetRef.current?.snapToIndex(0);
|
||||
return stillOpen;
|
||||
});
|
||||
},
|
||||
[onClose, tab.tabId],
|
||||
);
|
||||
// Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns Back.
|
||||
const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused;
|
||||
useEffect(() => {
|
||||
if (!ownsBackPress) return;
|
||||
return systemBackPress.subscribe(() => {
|
||||
close();
|
||||
return true;
|
||||
});
|
||||
}, [close, ownsBackPress]);
|
||||
|
||||
const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: descriptor.label,
|
||||
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
|
||||
// The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into
|
||||
// the switcher, where it gets its composer back.
|
||||
actions: sheet.policy.openAsTab ? (
|
||||
<SheetHeaderIconButton
|
||||
icon={Maximize2}
|
||||
accessibilityLabel={t("workspace.sheet.openAsTab")}
|
||||
onPress={handleOpenAsTab}
|
||||
testID="sheet-tab-open-as-tab"
|
||||
/>
|
||||
) : undefined,
|
||||
}),
|
||||
[descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t],
|
||||
);
|
||||
const handleValue = useMemo<SheetTabHandleValue>(
|
||||
() => ({ header, onClose: close }),
|
||||
[close, header],
|
||||
);
|
||||
// The header is the sheet's handle: the panel scrolls on its own, so dragging the content would
|
||||
// fight it, and the header is where people reach to pull the sheet down.
|
||||
const SheetTabHandle = useSheetTabHandle(handleValue);
|
||||
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={SNAP_POINTS}
|
||||
enableDynamicSizing={false}
|
||||
enablePanDownToClose
|
||||
enableContentPanningGesture={false}
|
||||
enableDismissOnClose={false}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleComponent={SheetTabHandle}
|
||||
backdropOpacity={0.45}
|
||||
onChange={handleChange}
|
||||
accessible={false}
|
||||
>
|
||||
{/* A covered sheet stays as live as the pane under it; only focus follows the top sheet. */}
|
||||
<SheetTabBody
|
||||
tab={tab}
|
||||
label={descriptor.label}
|
||||
isPaneFocused={isTop && isWorkspaceFocused}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
buildContent={buildContent}
|
||||
/>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTabBody({
|
||||
tab,
|
||||
label,
|
||||
isPaneFocused,
|
||||
isWorkspaceFocused,
|
||||
buildContent,
|
||||
}: {
|
||||
tab: SheetTab["tab"];
|
||||
label: string;
|
||||
/** Only the top sheet's pane is focused; the ones below are covered. */
|
||||
isPaneFocused: boolean;
|
||||
isWorkspaceFocused: boolean;
|
||||
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
|
||||
}) {
|
||||
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
|
||||
() => ({
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
state: tab.state,
|
||||
}),
|
||||
[tab],
|
||||
);
|
||||
const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]);
|
||||
// The sheet reaches the bottom of the screen, so it owns the system navigation inset.
|
||||
const { bottom: bottomInset } = useSafeAreaInsets();
|
||||
const contentStyle = useMemo(
|
||||
() => [styles.content, { paddingBottom: bottomInset }],
|
||||
[bottomInset],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={contentStyle} role="dialog" aria-label={label} testID="sheet-tab-body">
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
useComposerTabFullscreen,
|
||||
} from "@/composer/editing-session";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
|
||||
import {
|
||||
@@ -78,7 +79,7 @@ import {
|
||||
selectExplorerSidebarPaneId,
|
||||
selectPaneFocusSuspended,
|
||||
type WorkspaceLayout,
|
||||
type WorkspaceSheet,
|
||||
type SheetTab,
|
||||
type WorkspaceTabPlacement,
|
||||
useWorkspaceLayoutStore,
|
||||
useWorkspaceLayoutStoreHydrated,
|
||||
@@ -165,7 +166,7 @@ import {
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { WorkspaceSheetHost } from "@/screens/workspace/workspace-sheet-host";
|
||||
import { SheetTabs } from "@/screens/workspace/sheet-tabs";
|
||||
import type { PaneHost } from "@/panels/panel-manifest";
|
||||
import { WorkspaceFocusProvider } from "@/workspace/focus";
|
||||
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
|
||||
@@ -208,7 +209,7 @@ import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request";
|
||||
|
||||
const NO_SHEETS: readonly WorkspaceSheet[] = [];
|
||||
const NO_SHEETS: readonly SheetTab[] = [];
|
||||
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
|
||||
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
|
||||
@@ -3552,14 +3553,15 @@ function WorkspaceScreenContent({
|
||||
composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId),
|
||||
});
|
||||
|
||||
// The sheet is the column's last child, so it covers the compact header as a modal.
|
||||
// Sheets opened from the compact column present inside it, so their panels read the workspace
|
||||
// providers, and they cover the compact header as a modal.
|
||||
const workspaceCenterColumn = isMobile ? (
|
||||
<WorkspacePanelProviders
|
||||
launcher={newTabLauncher}
|
||||
workspaceKey={workspacePanelKey}
|
||||
tabIds={workspacePanelTabIds}
|
||||
>
|
||||
<View style={styles.centerColumn}>
|
||||
<SheetHostScope style={styles.centerColumn}>
|
||||
<FloatingHeaderLayout
|
||||
header={compactHeaderLayout.header}
|
||||
floating={compactHeaderLayout.floating}
|
||||
@@ -3570,17 +3572,15 @@ function WorkspaceScreenContent({
|
||||
</StreamTopOverlayProvider>
|
||||
)}
|
||||
</FloatingHeaderLayout>
|
||||
<StreamTopOverlayProvider value={0}>
|
||||
<WorkspaceSheetHost
|
||||
workspaceKey={persistenceKey}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isRouteFocused}
|
||||
buildContent={buildSheetPaneContentModel}
|
||||
onCloseTab={handleCloseTabById}
|
||||
/>
|
||||
</StreamTopOverlayProvider>
|
||||
</View>
|
||||
<SheetTabs
|
||||
workspaceKey={persistenceKey}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isRouteFocused}
|
||||
buildContent={buildSheetPaneContentModel}
|
||||
onCloseTab={handleCloseTabById}
|
||||
/>
|
||||
</SheetHostScope>
|
||||
</WorkspacePanelProviders>
|
||||
) : (
|
||||
<View style={styles.centerColumn}>
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
import BottomSheet, {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
type BottomSheetHandleProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import {
|
||||
type ComponentType,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
SheetBackground,
|
||||
SheetHandle,
|
||||
SheetHeaderView,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { systemBackPress } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
WorkspacePaneContent,
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import { useWorkspaceLayoutStore, type WorkspaceSheet } from "@/stores/workspace-layout-store";
|
||||
|
||||
const SNAP_POINTS = ["92%"];
|
||||
const NO_SHEETS: readonly WorkspaceSheet[] = [];
|
||||
|
||||
function PanelIcon({
|
||||
icon: Icon,
|
||||
size,
|
||||
color,
|
||||
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
// The workspace sheet is a Gorhom sheet rendered in place rather than a portaled modal, so the
|
||||
// backdrop closes the sheet itself instead of dismissing the top of the modal stack.
|
||||
function renderBackdrop(props: BottomSheetBackdropProps) {
|
||||
return (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
opacity={0.45}
|
||||
pressBehavior="close"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Stacked sheets share the bottom sheet's dim rather than darkening it again, but still catch the
|
||||
// tap so it closes the top sheet. The backdrop is clear rather than transparent: iOS skips views
|
||||
// under 0.01 opacity when hit testing, which would hand the tap to the sheet below.
|
||||
function renderStackedBackdrop(props: BottomSheetBackdropProps) {
|
||||
return (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
style={styles.clearBackdrop}
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
opacity={1}
|
||||
pressBehavior="close"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceSheetHandleValue {
|
||||
header: SheetHeader;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const WorkspaceSheetHandleContext = createContext<WorkspaceSheetHandleValue | null>(null);
|
||||
|
||||
// Gorhom renders `handleComponent` as a component type, so it stays one module-level component and
|
||||
// reads the header from context; a per-render function would remount the header on every change.
|
||||
function WorkspaceSheetHandle(props: BottomSheetHandleProps) {
|
||||
const value = useContext(WorkspaceSheetHandleContext);
|
||||
if (!value) throw new Error("WorkspaceSheetHandle must render inside a workspace sheet");
|
||||
return (
|
||||
<SheetHandle {...props}>
|
||||
<SheetHeaderView
|
||||
header={value.header}
|
||||
onClose={value.onClose}
|
||||
testID="workspace-sheet-header"
|
||||
/>
|
||||
</SheetHandle>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkspaceSheetHostProps {
|
||||
workspaceKey: string | null;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
isWorkspaceFocused: boolean;
|
||||
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
|
||||
/** The workspace's ordinary tab close, including its unsaved-changes guard. */
|
||||
onCloseTab: (tabId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface WorkspaceSheetPresentationProps extends Omit<
|
||||
WorkspaceSheetHostProps,
|
||||
"workspaceKey" | "onCloseTab"
|
||||
> {
|
||||
sheet: WorkspaceSheet;
|
||||
/** Only the bottom sheet dims the workspace; the ones above share its dim. */
|
||||
isBottom: boolean;
|
||||
/** Only the top sheet answers Android Back; the ones below wait their turn. */
|
||||
isTop: boolean;
|
||||
onClose: (tabId: string) => Promise<void>;
|
||||
onOpenAsTab: (tabId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the workspace's sheets over the compact tab strip, one Gorhom sheet per entry, top last.
|
||||
* They render in the workspace tree, not through a portal, so each panel keeps every context an
|
||||
* ordinary pane has. The shared sheet parts (`SheetBackground`, `SheetHandle`, `SheetHeaderView`)
|
||||
* keep them looking and dragging like every other sheet.
|
||||
*/
|
||||
export function WorkspaceSheetHost({ workspaceKey, onCloseTab, ...rest }: WorkspaceSheetHostProps) {
|
||||
const sheets = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
|
||||
);
|
||||
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
|
||||
const handleOpenAsTab = useCallback(
|
||||
(tabId: string) => {
|
||||
if (workspaceKey) openSheetAsTab(workspaceKey, tabId);
|
||||
},
|
||||
[openSheetAsTab, workspaceKey],
|
||||
);
|
||||
if (sheets.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.host} pointerEvents="box-none">
|
||||
{sheets.map((sheet, index) => (
|
||||
<WorkspaceSheetPresentation
|
||||
// Keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
|
||||
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
|
||||
{...rest}
|
||||
sheet={sheet}
|
||||
isBottom={index === 0}
|
||||
isTop={index === sheets.length - 1}
|
||||
onClose={onCloseTab}
|
||||
onOpenAsTab={handleOpenAsTab}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceSheetPresentation({
|
||||
sheet,
|
||||
isBottom,
|
||||
isTop,
|
||||
serverId,
|
||||
workspaceId,
|
||||
isWorkspaceFocused,
|
||||
buildContent,
|
||||
onClose,
|
||||
onOpenAsTab,
|
||||
}: WorkspaceSheetPresentationProps) {
|
||||
const { t } = useTranslation();
|
||||
const { tab } = sheet;
|
||||
const sheetRef = useRef<BottomSheet>(null);
|
||||
const isScreenFocused = useIsFocused();
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.target.kind);
|
||||
if (!registration) {
|
||||
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
|
||||
}
|
||||
const descriptor = registration.useDescriptor(tab.target as never, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId: tab.tabId,
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => onClose(tab.tabId), [onClose, tab.tabId]);
|
||||
const handleChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index !== -1) return;
|
||||
// A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up.
|
||||
void handleClose().then(() => sheetRef.current?.snapToIndex(0));
|
||||
},
|
||||
[handleClose],
|
||||
);
|
||||
// Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back.
|
||||
const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused;
|
||||
useEffect(() => {
|
||||
if (!ownsBackPress) return;
|
||||
return systemBackPress.subscribe(() => {
|
||||
void handleClose();
|
||||
return true;
|
||||
});
|
||||
}, [handleClose, ownsBackPress]);
|
||||
|
||||
const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: descriptor.label,
|
||||
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
|
||||
actions: sheet.policy.openAsTab ? (
|
||||
<Button size="sm" variant="secondary" onPress={handleOpenAsTab}>
|
||||
{t("workspace.sheet.openAsTab")}
|
||||
</Button>
|
||||
) : undefined,
|
||||
}),
|
||||
[descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t],
|
||||
);
|
||||
const handleHeaderClose = useCallback(() => {
|
||||
void handleClose();
|
||||
}, [handleClose]);
|
||||
const handleValue = useMemo<WorkspaceSheetHandleValue>(
|
||||
() => ({ header, onClose: handleHeaderClose }),
|
||||
[handleHeaderClose, header],
|
||||
);
|
||||
|
||||
return (
|
||||
// Gorhom renders the handle beside the content, so the dialog wraps the whole sheet.
|
||||
<View
|
||||
style={styles.sheetLayer}
|
||||
pointerEvents="box-none"
|
||||
role="dialog"
|
||||
aria-label={descriptor.label}
|
||||
>
|
||||
{/* The header is the sheet's handle: the panel scrolls on its own, so dragging the content
|
||||
would fight it, and the header is where people reach to pull the sheet down. */}
|
||||
<WorkspaceSheetHandleContext.Provider value={handleValue}>
|
||||
<BottomSheet
|
||||
ref={sheetRef}
|
||||
index={0}
|
||||
snapPoints={SNAP_POINTS}
|
||||
enableDynamicSizing={false}
|
||||
enablePanDownToClose
|
||||
enableContentPanningGesture={false}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleComponent={WorkspaceSheetHandle}
|
||||
backdropComponent={isBottom ? renderBackdrop : renderStackedBackdrop}
|
||||
onChange={handleChange}
|
||||
accessible={false}
|
||||
>
|
||||
{/* A covered sheet keeps its state but stops its live work until it is on top again. */}
|
||||
<RetainedPanelActivity active={isTop}>
|
||||
<WorkspaceSheetBody
|
||||
tab={tab}
|
||||
isPaneFocused={isTop && isWorkspaceFocused}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
buildContent={buildContent}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
</BottomSheet>
|
||||
</WorkspaceSheetHandleContext.Provider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceSheetBody({
|
||||
tab,
|
||||
isPaneFocused,
|
||||
isWorkspaceFocused,
|
||||
buildContent,
|
||||
}: {
|
||||
tab: WorkspaceSheet["tab"];
|
||||
/** Only the top sheet's pane is focused; the ones below are covered. */
|
||||
isPaneFocused: boolean;
|
||||
isWorkspaceFocused: boolean;
|
||||
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
|
||||
}) {
|
||||
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
|
||||
() => ({
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
state: tab.state,
|
||||
}),
|
||||
[tab],
|
||||
);
|
||||
const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]);
|
||||
// The sheet reaches the bottom of the screen, so it owns the system navigation inset.
|
||||
const { bottom: bottomInset } = useSafeAreaInsets();
|
||||
const contentStyle = useMemo(
|
||||
() => [styles.content, { paddingBottom: bottomInset }],
|
||||
[bottomInset],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={contentStyle} testID="workspace-sheet-body">
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
host: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
clearBackdrop: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
sheetLayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
});
|
||||
@@ -4592,7 +4592,7 @@ describe("compact workspace sheet", () => {
|
||||
expect(tabKinds(store)).toEqual(["terminal"]);
|
||||
});
|
||||
|
||||
it("brings a stacked target back to the top by dropping the sheets above it", () => {
|
||||
it("uncovers a covered sheet by dropping the sheets above it", () => {
|
||||
const store = createCompactStore();
|
||||
const fileTabId = store
|
||||
.getState()
|
||||
@@ -4878,13 +4878,15 @@ describe("compact workspace sheet", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("brings a covered sheet back to the top and keeps a modified sheet above it beneath", () => {
|
||||
it("keeps a covered sheet in place under a modified sheet instead of reordering", () => {
|
||||
const store = createCompactStore();
|
||||
const otherTarget = { kind: "file" as const, path: "src/other.ts" };
|
||||
const otherTabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: otherTarget, intent: "reveal" });
|
||||
const modifiedSheetTabId = openModifiedFileSheet(store);
|
||||
const unmodifiedTopTarget = { kind: "file" as const, path: "src/third.ts" };
|
||||
store.getState().openTab({ workspaceKey, target: unmodifiedTopTarget, intent: "reveal" });
|
||||
const focusedBefore = focusedTabId(store);
|
||||
|
||||
const tabId = store
|
||||
@@ -4892,10 +4894,10 @@ describe("compact workspace sheet", () => {
|
||||
.openTab({ workspaceKey, target: otherTarget, intent: "reveal" });
|
||||
|
||||
expect(tabId).toBe(otherTabId);
|
||||
expect(sheetTargets(store)).toEqual([fileTarget, otherTarget]);
|
||||
expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.tabId).toBe(
|
||||
modifiedSheetTabId,
|
||||
);
|
||||
// Moving a sheet would remount its panel, so the stack keeps its order: the modified sheet
|
||||
// stays on top of the one revealed, and only the unmodified sheet above them closes.
|
||||
expect(sheetTargets(store)).toEqual([otherTarget, fileTarget]);
|
||||
expect(sheetTabId(store)).toBe(modifiedSheetTabId);
|
||||
expect(stripTab(store, tabId ?? "")).toBeNull();
|
||||
expect(focusedTabId(store)).toBe(focusedBefore);
|
||||
});
|
||||
|
||||
@@ -111,13 +111,13 @@ export interface OpenWorkspaceTabInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* How the app shell lays out a workspace: compact (one tab at a time, plus the sheet), a single
|
||||
* How the app shell lays out a workspace: compact (one tab at a time, plus sheets), a single
|
||||
* wide pane (touch tablets, which cannot render splits), or split panes.
|
||||
*/
|
||||
export type WorkspaceLayoutMode = "compact" | "single-pane" | "split-panes";
|
||||
|
||||
/** A tab hosted by the compact workspace sheet instead of a pane. */
|
||||
export interface WorkspaceSheet {
|
||||
/** A tab shown in a sheet over the compact workspace instead of in a pane. */
|
||||
export interface SheetTab {
|
||||
tab: WorkspaceTab;
|
||||
policy: CompactSheetPolicy;
|
||||
parentTabId: string | null;
|
||||
@@ -127,10 +127,12 @@ interface WorkspaceLayoutStore {
|
||||
/** Published once by the app shell; callers never pass a form factor to open a tab. */
|
||||
layoutMode: WorkspaceLayoutMode;
|
||||
/**
|
||||
* Each workspace's open sheets, bottom to top; only the top one is on screen. A workspace with
|
||||
* no sheet has no entry. Not persisted: a reload returns to the tab strip.
|
||||
* 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
|
||||
* its panel. A workspace with no sheet has no entry. Not persisted: a reload returns to the tab
|
||||
* strip.
|
||||
*/
|
||||
sheetsByWorkspace: Record<string, readonly WorkspaceSheet[]>;
|
||||
sheetsByWorkspace: Record<string, readonly SheetTab[]>;
|
||||
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||
explorerSidebarWidthByWorkspace: Record<string, number>;
|
||||
@@ -143,10 +145,10 @@ interface WorkspaceLayoutStore {
|
||||
pullRequestTabAutoOpenedByWorkspace: Record<string, true>;
|
||||
/**
|
||||
* The one entry point for showing a target. On compact layouts a target whose panel declares
|
||||
* `compactSheet` opens in the workspace sheet; the returned tab id is the sheet tab's id.
|
||||
* `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id.
|
||||
*/
|
||||
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
||||
/** Whether `openTab` with this input would show the target in the sheet. */
|
||||
/** Whether `openTab` with this input would show the target in a sheet. */
|
||||
opensInSheet: (
|
||||
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
|
||||
) => boolean;
|
||||
@@ -684,12 +686,12 @@ function attachParentTab(input: {
|
||||
});
|
||||
}
|
||||
|
||||
const NO_SHEETS: readonly WorkspaceSheet[] = [];
|
||||
const NO_SHEETS: readonly SheetTab[] = [];
|
||||
|
||||
function getSheets(
|
||||
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
|
||||
workspaceKey: string,
|
||||
): readonly WorkspaceSheet[] {
|
||||
): readonly SheetTab[] {
|
||||
return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS;
|
||||
}
|
||||
|
||||
@@ -697,14 +699,14 @@ function findSheet(
|
||||
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
|
||||
workspaceKey: string,
|
||||
tabId: string,
|
||||
): WorkspaceSheet | undefined {
|
||||
): SheetTab | undefined {
|
||||
return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId);
|
||||
}
|
||||
|
||||
function withSheets(
|
||||
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
|
||||
workspaceKey: string,
|
||||
sheets: readonly WorkspaceSheet[],
|
||||
sheets: readonly SheetTab[],
|
||||
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
|
||||
if (sheets.length > 0) {
|
||||
return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } };
|
||||
@@ -732,7 +734,7 @@ function withSheetTab(
|
||||
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
|
||||
workspaceKey: string,
|
||||
tabId: string,
|
||||
update: (sheet: WorkspaceSheet) => WorkspaceSheet,
|
||||
update: (sheet: SheetTab) => SheetTab,
|
||||
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
|
||||
return withSheets(
|
||||
state,
|
||||
@@ -777,13 +779,13 @@ function resolveOpenParentTabId(
|
||||
|
||||
function placeTargetInSheet(input: {
|
||||
/** The sheet whose tab the target keeps, if any. */
|
||||
kept: WorkspaceSheet | undefined;
|
||||
kept: SheetTab | undefined;
|
||||
target: WorkspaceTabTarget;
|
||||
intent: WorkspaceTabOpenIntent;
|
||||
policy: CompactSheetPolicy;
|
||||
parentTabId: string | null;
|
||||
state: JsonValue | undefined;
|
||||
}): WorkspaceSheet {
|
||||
}): SheetTab {
|
||||
if (input.kept) {
|
||||
return { ...input.kept, tab: { ...input.kept.tab, target: input.target } };
|
||||
}
|
||||
@@ -857,14 +859,14 @@ function promoteSheetToTab(
|
||||
* A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the
|
||||
* panel and drop the work. Only the explicit, guarded close removes it.
|
||||
*/
|
||||
function sheetHoldsUnsavedWork(workspaceKey: string, sheet: WorkspaceSheet): boolean {
|
||||
function sheetHoldsUnsavedWork(workspaceKey: string, sheet: SheetTab): boolean {
|
||||
return isSheetTabModified(workspaceKey, sheet.tab.tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an open lands in the sheet stack, or null when it opens as a tab. A new target goes on
|
||||
* top. A reveal of a resource a sheet already shows keeps that sheet's tab and brings it back to
|
||||
* the top; the sheets above it close, except ones holding unsaved work, which stay beneath it.
|
||||
* Where an open lands among the sheet tabs, or null when it opens as a tab. A new target goes on
|
||||
* top. A reveal of a resource a sheet already shows keeps that sheet's tab and uncovers it by
|
||||
* closing the sheets above it, except ones holding unsaved work, which stay where they are.
|
||||
*/
|
||||
function resolveSheetPlacement(
|
||||
state: WorkspaceLayoutStore,
|
||||
@@ -915,7 +917,7 @@ function openSheetTabInFront(
|
||||
*/
|
||||
function sheetAgentNoLongerFits(
|
||||
workspaceKey: string,
|
||||
sheet: WorkspaceSheet,
|
||||
sheet: SheetTab,
|
||||
snapshot: WorkspaceTabSnapshot,
|
||||
): boolean {
|
||||
if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
|
||||
@@ -1001,16 +1003,17 @@ export function createWorkspaceLayoutStore(
|
||||
parentTabId,
|
||||
state: input.state,
|
||||
});
|
||||
const below =
|
||||
const next =
|
||||
keptIndex === null
|
||||
? sheets
|
||||
: [
|
||||
...sheets.slice(0, keptIndex),
|
||||
...sheets
|
||||
.slice(keptIndex + 1)
|
||||
.filter((above) => sheetHoldsUnsavedWork(normalizedWorkspaceKey, above)),
|
||||
];
|
||||
set((state) => withSheets(state, normalizedWorkspaceKey, [...below, sheet]));
|
||||
? [...sheets, sheet]
|
||||
: sheets.flatMap((existing, index) => {
|
||||
if (index === keptIndex) return [sheet];
|
||||
if (index < keptIndex) return [existing];
|
||||
return sheetHoldsUnsavedWork(normalizedWorkspaceKey, existing)
|
||||
? [existing]
|
||||
: [];
|
||||
});
|
||||
set((state) => withSheets(state, normalizedWorkspaceKey, next));
|
||||
return sheet.tab.tabId;
|
||||
}
|
||||
const placement = getOpenTabPlacement(
|
||||
|
||||
@@ -9,3 +9,4 @@ export {
|
||||
type ArchiveFinishedStatus,
|
||||
type UseArchiveFinishedSubagentsInput,
|
||||
} from "./use-archive-finished";
|
||||
export { useSubagentOpensInSheet } from "./opens-in-sheet";
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { useSubagentOpensInSheet } from "./opens-in-sheet";
|
||||
import { selectSubagentsForParent, type SubagentRow } from "./select";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "ws-main";
|
||||
const AGENT_TIMESTAMP = new Date("2026-04-21T10:00:00.000Z");
|
||||
|
||||
const AGENT_DEFAULTS: Agent = {
|
||||
serverId: SERVER_ID,
|
||||
id: "agent",
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
createdAt: AGENT_TIMESTAMP,
|
||||
updatedAt: AGENT_TIMESTAMP,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: AGENT_TIMESTAMP,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: undefined,
|
||||
lastUsage: undefined,
|
||||
lastError: null,
|
||||
title: "Agent",
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
model: null,
|
||||
features: undefined,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
if (!workspaceKey) throw new Error("workspace key");
|
||||
|
||||
function seedParentWithChild(): SubagentRow {
|
||||
const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" };
|
||||
const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" };
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents(
|
||||
SERVER_ID,
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[child.id, child],
|
||||
]),
|
||||
);
|
||||
const [row] = selectSubagentsForParent(
|
||||
useSessionStore.getState(),
|
||||
{ serverId: SERVER_ID, parentAgentId: "parent" },
|
||||
new Set(),
|
||||
);
|
||||
if (!row) throw new Error("child row");
|
||||
return row;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
useWorkspaceLayoutStore.setState({
|
||||
layoutMode: "split-panes",
|
||||
layoutByWorkspace: {},
|
||||
sheetsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSubagentOpensInSheet", () => {
|
||||
it("re-renders the row's answer as the child's tab opens and closes", () => {
|
||||
const row = seedParentWithChild();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
store.setLayoutMode("compact");
|
||||
const target = { kind: "agent", agentId: "child" } as const;
|
||||
|
||||
// The row renders its answer, as the track does, so a stale render is a stale answer.
|
||||
const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row));
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
let tabId: string | null = null;
|
||||
act(() => {
|
||||
const sheetTabId = store.openTab({ workspaceKey, target, intent: "reveal" });
|
||||
tabId = sheetTabId ? store.openSheetAsTab(workspaceKey, sheetTabId) : null;
|
||||
});
|
||||
expect(tabId).not.toBeNull();
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
store.closeTab(workspaceKey, tabId ?? "");
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { SubagentRow } from "./select";
|
||||
|
||||
/** The workspace target a subagent row opens. */
|
||||
export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget {
|
||||
return row.kind === "provider"
|
||||
? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id }
|
||||
: { kind: "agent", agentId: row.id };
|
||||
}
|
||||
|
||||
/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */
|
||||
export function useSubagentOpensInSheet(
|
||||
workspaceKey: string | null,
|
||||
): (row: SubagentRow) => boolean {
|
||||
// The rows render the answer, so this subscribes to what the answer reads: the layout mode and
|
||||
// the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet,
|
||||
// and a row rendered while that tab existed must re-render once it closes, or choosing the row
|
||||
// would still close the list it sits in.
|
||||
useWorkspaceLayoutStore((state) => state.layoutMode);
|
||||
useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
return (row) =>
|
||||
workspaceKey !== null &&
|
||||
useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" });
|
||||
}
|
||||
@@ -35,6 +35,11 @@ export interface SubagentsTrackProps {
|
||||
rows: SubagentRow[];
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
/**
|
||||
* Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on
|
||||
* close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list.
|
||||
*/
|
||||
opensInSheet: (row: SubagentRow) => boolean;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onArchiveFinished?: () => void;
|
||||
archiveFinishedStatus?: ArchiveFinishedStatus;
|
||||
@@ -63,6 +68,7 @@ export function SubagentsTrack({
|
||||
rows,
|
||||
onOpenSubagent,
|
||||
onOpenProviderSubagent,
|
||||
opensInSheet,
|
||||
onArchiveSubagent,
|
||||
onArchiveFinished,
|
||||
archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS,
|
||||
@@ -103,6 +109,7 @@ export function SubagentsTrack({
|
||||
serverId={serverId}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||
opensInSheet={opensInSheet(row)}
|
||||
onArchiveSubagent={onArchiveSubagent}
|
||||
onDetachSubagent={onDetachSubagent}
|
||||
/>
|
||||
@@ -174,6 +181,7 @@ interface SubagentsTrackRowProps {
|
||||
row: SubagentRow;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
opensInSheet: boolean;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
@@ -183,6 +191,7 @@ function SubagentsTrackRow({
|
||||
row,
|
||||
onOpenSubagent,
|
||||
onOpenProviderSubagent,
|
||||
opensInSheet,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackRowProps): ReactElement {
|
||||
@@ -245,6 +254,7 @@ function SubagentsTrackRow({
|
||||
<ComposerTrackRow
|
||||
accessibilityLabel={displayLabel}
|
||||
testID={`subagents-track-row-${row.id}`}
|
||||
closeOnSelect={!opensInSheet}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{renderRow}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { localFileSourceToPath } from "@/attachments/utils";
|
||||
import { createAssistantMarkdownParser } from "./assistant-markdown-parser";
|
||||
import { resolveAssistantImageSource } from "./assistant-image-source";
|
||||
|
||||
describe("local assistant Markdown images", () => {
|
||||
let workspaceRoot: string;
|
||||
const imageBytes = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "assistant-markdown-image-"));
|
||||
await mkdir(path.join(workspaceRoot, "My Apps", "Мои"), { recursive: true });
|
||||
await writeFile(path.join(workspaceRoot, "My Apps", "Мои", "test.png"), imageBytes);
|
||||
await writeFile(path.join(workspaceRoot, "literal%20name.png"), imageBytes);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspaceRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"absolute angle",
|
||||
"absolute encoded",
|
||||
"relative angle",
|
||||
"relative encoded",
|
||||
"file URI",
|
||||
"literal percent",
|
||||
])("reads the actual image for the %s destination", async (form) => {
|
||||
const absolute = path.join(workspaceRoot, "My Apps", "Мои", "test.png");
|
||||
const destinations: Record<string, string> = {
|
||||
"absolute angle": `<${absolute}>`,
|
||||
"absolute encoded": encodeURI(absolute),
|
||||
"relative angle": "<My Apps/Мои/test.png>",
|
||||
"relative encoded": encodeURI("My Apps/Мои/test.png"),
|
||||
"file URI": `file://${encodeURI(absolute)}`,
|
||||
"literal percent": `${workspaceRoot}/literal%2520name.png`,
|
||||
};
|
||||
const tokens = createAssistantMarkdownParser().parseInline(
|
||||
``,
|
||||
{},
|
||||
);
|
||||
const image = tokens
|
||||
.flatMap((token) => token.children ?? [])
|
||||
.find((token) => token.type === "image");
|
||||
expect(image).toBeDefined();
|
||||
const source = localFileSourceToPath(image!.attrGet("src")!, { decodeHref: true });
|
||||
const target = resolveAssistantImageSource({ source, workspaceRoot });
|
||||
expect(target?.kind).toBe("file_rpc");
|
||||
if (target?.kind !== "file_rpc") throw new Error("Expected a local image read target");
|
||||
await expect(readFile(path.resolve(target.cwd, target.path))).resolves.toEqual(imageBytes);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
// to keep the module graph importable.
|
||||
const Stub = () => null;
|
||||
const PassThrough = ({ children }: { children?: unknown }) => children;
|
||||
const sharedValue = (value: unknown) => ({ get: () => value });
|
||||
|
||||
export default Stub;
|
||||
export const BottomSheetModal = Stub;
|
||||
@@ -17,10 +18,27 @@ export function useBottomSheetModal(): never {
|
||||
throw new Error("Modal stack interactions require the real Gorhom provider in browser E2E tests");
|
||||
}
|
||||
|
||||
// A sheet host scope only redirects where sheets draw; with no sheet drawn there is nothing to host.
|
||||
export function useBottomSheet(): never {
|
||||
throw new Error("useBottomSheet is unavailable in tests");
|
||||
}
|
||||
export const BottomSheetModalInternalProvider = PassThrough;
|
||||
export const INITIAL_CONTAINER_LAYOUT = {
|
||||
height: 0,
|
||||
offset: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
};
|
||||
export function useBottomSheetModalInternal() {
|
||||
return {
|
||||
hostName: "stub",
|
||||
containerLayoutState: sharedValue(INITIAL_CONTAINER_LAYOUT),
|
||||
mountSheet: () => undefined,
|
||||
unmountSheet: () => undefined,
|
||||
willUnmountSheet: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// The app imports the internal layout values to compute the visible sheet body. Browser unit tests
|
||||
// never render the native sheet, but Vite still validates this module's export surface.
|
||||
const sharedValue = (value: unknown) => ({ get: () => value });
|
||||
|
||||
export const KEYBOARD_STATUS = { SHOWN: "SHOWN" };
|
||||
export function useBottomSheetInternal() {
|
||||
return {
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -3098,7 +3098,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.cachedCommands = update.availableCommands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
argumentHint: "",
|
||||
argumentHint: command.input?.hint ?? "",
|
||||
kind: "command",
|
||||
}));
|
||||
this.settleCommandsReady();
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
|
||||
// Explicit real-provider test: run separately from the default suite.
|
||||
test("uses the active model context window after switching from a 1M model", async () => {
|
||||
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
|
||||
const session = await client.createSession(
|
||||
{ provider: "claude", cwd: process.cwd(), model: "claude-haiku-4-5" },
|
||||
undefined,
|
||||
{ persistSession: false },
|
||||
);
|
||||
try {
|
||||
for (const [model, maxTokens] of [
|
||||
["claude-haiku-4-5", 200_000],
|
||||
["claude-opus-5-5", 1_000_000],
|
||||
["claude-haiku-4-5", 200_000],
|
||||
] as const) {
|
||||
await session.setModel(model);
|
||||
const events = await collectSessionTurnEvents(
|
||||
session,
|
||||
"Reply with exactly: OK. Do not use tools.",
|
||||
{ signal: AbortSignal.timeout(90_000) },
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
usage: expect.objectContaining({ contextWindowMaxTokens: maxTokens }),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "turn_completed",
|
||||
usage: expect.objectContaining({ contextWindowMaxTokens: maxTokens }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}, 300_000);
|
||||
@@ -6,12 +6,28 @@ import type { ClaudeQueryFactory } from "./query.js";
|
||||
|
||||
// A query dependency that speaks the same stream interface as Claude Code.
|
||||
// Each request repeats init so a second turn exercises runtime-limit retention.
|
||||
function contextQuery(runtimeModel: string, reportedLimit: number): ClaudeQueryFactory {
|
||||
function contextQuery({
|
||||
runtimeModel,
|
||||
reportedLimit,
|
||||
modelWindowsByTurn,
|
||||
}: {
|
||||
runtimeModel: string;
|
||||
reportedLimit: number;
|
||||
modelWindowsByTurn?: Array<Record<string, number>>;
|
||||
}): ClaudeQueryFactory {
|
||||
return ({ prompt }) => {
|
||||
let model = runtimeModel;
|
||||
let turn = 0;
|
||||
const messages = (async function* () {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
for await (const _message of prompt) {
|
||||
const modelWindows = modelWindowsByTurn?.[turn] ?? { [model]: reportedLimit };
|
||||
turn += 1;
|
||||
const usageEntries = Object.entries(modelWindows).map(([id, contextWindow]) => [
|
||||
id,
|
||||
{ contextWindow },
|
||||
]);
|
||||
const modelUsage = Object.fromEntries(usageEntries);
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
@@ -41,7 +57,7 @@ function contextQuery(runtimeModel: string, reportedLimit: number): ClaudeQueryF
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0,
|
||||
usage: { input_tokens: 249_950, output_tokens: 50, cache_read_input_tokens: 0 },
|
||||
modelUsage: { [model]: { contextWindow: reportedLimit } },
|
||||
modelUsage,
|
||||
permission_denials: [],
|
||||
uuid: "context-result",
|
||||
};
|
||||
@@ -71,7 +87,7 @@ test.each([
|
||||
async ({ selected, runtime, expected }) => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: contextQuery(runtime, 800_000),
|
||||
queryFactory: contextQuery({ runtimeModel: runtime, reportedLimit: 800_000 }),
|
||||
resolveBinary: async () => process.execPath,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
@@ -110,3 +126,93 @@ test.each([
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("completed turns use the active model's window after switching both ways", async () => {
|
||||
const haiku = "claude-haiku-4-5";
|
||||
const opus = "claude-opus-5-5";
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => process.execPath,
|
||||
queryFactory: contextQuery({
|
||||
runtimeModel: haiku,
|
||||
reportedLimit: 200_000,
|
||||
modelWindowsByTurn: [
|
||||
{ [haiku]: 200_000 },
|
||||
{ [haiku]: 200_000, [opus]: 1_000_000 },
|
||||
{ [haiku]: 200_000, [opus]: 1_000_000 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: haiku,
|
||||
});
|
||||
try {
|
||||
for (const [model, limit] of [
|
||||
[haiku, 200_000],
|
||||
[opus, 1_000_000],
|
||||
[haiku, 200_000],
|
||||
] as const) {
|
||||
await session.setModel(model);
|
||||
const events = await collectSessionTurnEvents(session, "OK");
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
usage: expect.objectContaining({ contextWindowMaxTokens: limit }),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "turn_completed",
|
||||
usage: expect.objectContaining({ contextWindowMaxTokens: limit }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "runtime-reported custom limit",
|
||||
model: "custom-model",
|
||||
windows: { "custom-model": 400_000, "prior-model": 1_000_000 },
|
||||
expected: 400_000,
|
||||
},
|
||||
{
|
||||
name: "missing active model entry",
|
||||
model: "claude-haiku-4-5",
|
||||
windows: { "prior-model": 1_000_000 },
|
||||
expected: 200_000,
|
||||
},
|
||||
{
|
||||
name: "invalid active model limit",
|
||||
model: "claude-haiku-4-5",
|
||||
windows: { "claude-haiku-4-5": -1, "prior-model": 1_000_000 },
|
||||
expected: 200_000,
|
||||
},
|
||||
])("preserves $name without using another model's window", async ({ model, windows, expected }) => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => process.execPath,
|
||||
queryFactory: contextQuery({
|
||||
runtimeModel: model,
|
||||
reportedLimit: expected,
|
||||
modelWindowsByTurn: [windows],
|
||||
}),
|
||||
});
|
||||
const session = await client.createSession({ provider: "claude", cwd: process.cwd(), model });
|
||||
try {
|
||||
const events = await collectSessionTurnEvents(session, "OK");
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "turn_completed",
|
||||
usage: expect.objectContaining({ contextWindowMaxTokens: expected }),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
|
||||
const SESSION_CWD = "/tmp/paseo-claude-renamed-import";
|
||||
|
||||
interface ClaudeTranscriptRecord {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let configDir: string;
|
||||
let previousConfigDir: string | undefined;
|
||||
|
||||
interface UserRecordInput {
|
||||
sessionId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface WriteSessionInput {
|
||||
sessionId: string;
|
||||
records: ClaudeTranscriptRecord[];
|
||||
}
|
||||
|
||||
function userRecord({ sessionId, content }: UserRecordInput): ClaudeTranscriptRecord {
|
||||
return {
|
||||
isSidechain: false,
|
||||
type: "user",
|
||||
message: { role: "user", content },
|
||||
cwd: SESSION_CWD,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeSession({ sessionId, records }: WriteSessionInput): Promise<void> {
|
||||
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, `${sessionId}.jsonl`),
|
||||
`${records.map((record) => JSON.stringify(record)).join("\n")}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Importing replays the transcript from disk, so this drives the real resume
|
||||
// path without starting Claude Code.
|
||||
async function importSession(sessionId: string) {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
return client.importSession(
|
||||
{ providerHandleId: sessionId, cwd: SESSION_CWD },
|
||||
{
|
||||
config: { provider: "claude", cwd: SESSION_CWD },
|
||||
storedConfig: { provider: "claude", cwd: SESSION_CWD },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-renamed-"));
|
||||
previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
||||
}
|
||||
await fs.rm(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("importing a renamed Claude session", () => {
|
||||
test.each(["provider", "launch"])(
|
||||
"reads the rename from the %s config directory used for history",
|
||||
async (source) => {
|
||||
const sessionId = "provider-directory-rename";
|
||||
const providerConfigDir = path.join(configDir, "provider");
|
||||
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir: providerConfigDir });
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, `${sessionId}.jsonl`),
|
||||
[
|
||||
userRecord({ sessionId, content: "Review this project" }),
|
||||
{ type: "custom-title", customTitle: "My research session", sessionId },
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join("\n") + "\n",
|
||||
);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtimeSettings:
|
||||
source === "provider" ? { env: { CLAUDE_CONFIG_DIR: providerConfigDir } } : undefined,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const imported = await client.importSession(
|
||||
{ providerHandleId: sessionId, cwd: SESSION_CWD },
|
||||
{
|
||||
config: { provider: "claude", cwd: SESSION_CWD },
|
||||
storedConfig: { provider: "claude", cwd: SESSION_CWD },
|
||||
launchContext:
|
||||
source === "launch" ? { env: { CLAUDE_CONFIG_DIR: providerConfigDir } } : undefined,
|
||||
},
|
||||
);
|
||||
expect(imported.timeline.map((entry) => entry.item)).toContainEqual(
|
||||
expect.objectContaining({ type: "user_message", text: "Review this project" }),
|
||||
);
|
||||
expect(imported.config.title).toBe("My research session");
|
||||
},
|
||||
);
|
||||
|
||||
test("names the imported agent after a /rename", async () => {
|
||||
const sessionId = "renamed-session";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
userRecord({ sessionId, content: "Review this project" }),
|
||||
{ type: "custom-title", customTitle: "My research session", sessionId },
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("My research session");
|
||||
});
|
||||
|
||||
test("uses the last rename when the session was renamed more than once", async () => {
|
||||
const sessionId = "renamed-twice";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
userRecord({ sessionId, content: "Move invoices to the new schema" }),
|
||||
{ type: "custom-title", customTitle: "invoices", sessionId },
|
||||
{ type: "custom-title", customTitle: "billing rework", sessionId },
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("billing rework");
|
||||
});
|
||||
|
||||
test("preserves a rename when the first prompt was /model", async () => {
|
||||
const sessionId = "model-first";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
userRecord({ sessionId, content: "/model" }),
|
||||
{ type: "custom-title", customTitle: "My research session", sessionId },
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("My research session");
|
||||
});
|
||||
|
||||
test("keeps the rename when a later record carries no usable name", async () => {
|
||||
const sessionId = "malformed-later-record";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
userRecord({ sessionId, content: "Review this project" }),
|
||||
{ type: "custom-title", customTitle: "My research session", sessionId },
|
||||
{ type: "custom-title", sessionId },
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("My research session");
|
||||
});
|
||||
|
||||
test("ignores a rename recorded on a sidechain transcript", async () => {
|
||||
const sessionId = "sidechain-rename";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
userRecord({ sessionId, content: "Review this project" }),
|
||||
{ type: "custom-title", customTitle: "Subagent title", sessionId, isSidechain: true },
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBeUndefined();
|
||||
});
|
||||
|
||||
test("leaves the agent untitled when the session was never renamed", async () => {
|
||||
const sessionId = "never-renamed";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [userRecord({ sessionId, content: "Review this project" })],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not name the imported agent after a generated ai-title", async () => {
|
||||
const sessionId = "auto-titled";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
userRecord({ sessionId, content: "Add pagination to the invoice list" }),
|
||||
{ type: "ai-title", aiTitle: "Invoice list pagination", sessionId },
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
// The Import Session list shows it, but agents are named from their first
|
||||
// prompt line.
|
||||
expect(imported.config.title).toBeUndefined();
|
||||
});
|
||||
|
||||
test("imports a session that has no transcript on disk", async () => {
|
||||
const imported = await importSession("no-such-session");
|
||||
|
||||
expect(imported.config.title).toBeUndefined();
|
||||
});
|
||||
|
||||
test("surfaces an unreadable transcript instead of importing it as never renamed", async () => {
|
||||
const sessionId = "unreadable-transcript";
|
||||
// A directory where the transcript belongs fails the read with EISDIR, the
|
||||
// deterministic stand-in for a permissions or I/O fault.
|
||||
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
|
||||
await fs.mkdir(path.join(projectDir, `${sessionId}.jsonl`), { recursive: true });
|
||||
|
||||
await expect(importSession(sessionId)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -203,24 +203,26 @@ function stripClaudeAskUserQuestionUiMetadata(input: AgentMetadata): AgentMetada
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
/**
|
||||
* The answers this response will actually deliver, keyed by full question text,
|
||||
* or null when there is no question list and answers map to work from.
|
||||
*
|
||||
* Shared by the normalizer and by the check that rejects an undeliverable
|
||||
* answer, so the two can never disagree about what "deliverable" means.
|
||||
*/
|
||||
function resolveClaudeAskUserQuestionAnswers(
|
||||
updatedInput: AgentMetadata | undefined,
|
||||
fallbackInput: AgentMetadata | undefined,
|
||||
): AgentMetadata {
|
||||
): Record<string, string> | null {
|
||||
const fallback = isMetadata(fallbackInput) ? fallbackInput : {};
|
||||
const base = isMetadata(updatedInput) ? updatedInput : {};
|
||||
// Paseo's shared question UI serializes answers by question header, but Claude's
|
||||
// AskUserQuestion tool expects answer keys to match the full question text. Merge
|
||||
// the original request payload back in so provider callbacks that only return
|
||||
// `{ answers }` still satisfy Claude's full tool input schema.
|
||||
const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base });
|
||||
const questions =
|
||||
(Array.isArray(base.questions) ? base.questions : null) ??
|
||||
(Array.isArray(fallback.questions) ? fallback.questions : null);
|
||||
const answers = isMetadata(base.answers) ? base.answers : null;
|
||||
|
||||
if (!questions || !answers) {
|
||||
return merged;
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedAnswers: Record<string, string> = {};
|
||||
@@ -244,7 +246,23 @@ export function normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(normalizedAnswers).length === 0) {
|
||||
return normalizedAnswers;
|
||||
}
|
||||
|
||||
export function normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
updatedInput: AgentMetadata | undefined,
|
||||
fallbackInput: AgentMetadata | undefined,
|
||||
): AgentMetadata {
|
||||
const fallback = isMetadata(fallbackInput) ? fallbackInput : {};
|
||||
const base = isMetadata(updatedInput) ? updatedInput : {};
|
||||
// Paseo's shared question UI serializes answers by question header, but Claude's
|
||||
// AskUserQuestion tool expects answer keys to match the full question text. Merge
|
||||
// the original request payload back in so provider callbacks that only return
|
||||
// `{ answers }` still satisfy Claude's full tool input schema.
|
||||
const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base });
|
||||
const normalizedAnswers = resolveClaudeAskUserQuestionAnswers(updatedInput, fallbackInput);
|
||||
|
||||
if (!normalizedAnswers || Object.keys(normalizedAnswers).length === 0) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -254,6 +272,32 @@ export function normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
};
|
||||
}
|
||||
|
||||
class ClaudeQuestionAnswerRequiredError extends Error {
|
||||
readonly code = "question_answer_required" as const;
|
||||
|
||||
constructor(readonly requestId: string) {
|
||||
super(
|
||||
`Permission request '${requestId}' requires updatedInput.answers keyed by a ` +
|
||||
`question's full text or header, with a non-empty string value. ` +
|
||||
`The request is still pending; retry with that shape.`,
|
||||
);
|
||||
this.name = "ClaudeQuestionAnswerRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
function assertClaudeQuestionAnswerDeliverable(
|
||||
request: AgentPermissionRequest,
|
||||
response: AgentPermissionResponse,
|
||||
): void {
|
||||
if (request.kind !== "question" || response.behavior !== "allow") {
|
||||
return;
|
||||
}
|
||||
const answers = resolveClaudeAskUserQuestionAnswers(response.updatedInput, request.input);
|
||||
if (!answers || Object.keys(answers).length === 0) {
|
||||
throw new ClaudeQuestionAnswerRequiredError(request.id);
|
||||
}
|
||||
}
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -1693,11 +1737,21 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
|
||||
// Carry a `/rename` onto the imported agent so the name chosen in the CLI
|
||||
// survives the import. Only an explicit rename counts: agents are named
|
||||
// after their first prompt line since #1563, so adopting the generated
|
||||
// `ai-title` here would put that LLM summary back.
|
||||
const renamedTitle = await readClaudeRenamedSessionTitle({
|
||||
cwd: input.cwd,
|
||||
sessionId: input.providerHandleId,
|
||||
configDir: claudeConfigDir(this.buildProviderEnv(context.launchContext?.env)),
|
||||
});
|
||||
return importSessionFromPersistence({
|
||||
provider: "claude",
|
||||
request: input,
|
||||
context,
|
||||
resumeSession: this.resumeSession.bind(this),
|
||||
...(renamedTitle ? { config: { title: renamedTitle } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1829,30 +1883,22 @@ async function resolveClaudeAuth(
|
||||
}
|
||||
}
|
||||
|
||||
function extractContextWindowSize(modelUsage: unknown): number | undefined {
|
||||
function extractContextWindowSize(
|
||||
modelUsage: unknown,
|
||||
modelId: string | null | undefined,
|
||||
): number | undefined {
|
||||
const usageRecord = toObjectRecord(modelUsage);
|
||||
if (!usageRecord) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let maxContextWindow: number | undefined;
|
||||
for (const value of Object.values(usageRecord)) {
|
||||
const valueRecord = toObjectRecord(value);
|
||||
if (!valueRecord) {
|
||||
continue;
|
||||
}
|
||||
const contextWindow = valueRecord.contextWindow;
|
||||
if (
|
||||
typeof contextWindow !== "number" ||
|
||||
!Number.isFinite(contextWindow) ||
|
||||
contextWindow <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow);
|
||||
}
|
||||
|
||||
return maxContextWindow;
|
||||
// modelUsage accumulates every model used in the session, including earlier turns
|
||||
// and subagents. Only the active model describes this turn's context capacity.
|
||||
const modelUsageEntry = modelId ? usageRecord[modelId] : undefined;
|
||||
const contextWindow = toObjectRecord(modelUsageEntry)?.contextWindow;
|
||||
return typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0
|
||||
? contextWindow
|
||||
: undefined;
|
||||
}
|
||||
|
||||
interface StreamRequestInputUsage {
|
||||
@@ -2006,8 +2052,8 @@ class ClaudeContextUsageState {
|
||||
this.contextWindowMaxTokens = contextWindowMaxTokens;
|
||||
}
|
||||
|
||||
recordModelUsage(modelUsage: unknown): number | undefined {
|
||||
const contextWindowMaxTokens = extractContextWindowSize(modelUsage);
|
||||
recordModelUsage(modelUsage: unknown, modelId: string | null | undefined): number | undefined {
|
||||
const contextWindowMaxTokens = extractContextWindowSize(modelUsage, modelId);
|
||||
if (contextWindowMaxTokens !== undefined) {
|
||||
this.contextWindowMaxTokens = contextWindowMaxTokens;
|
||||
}
|
||||
@@ -2050,7 +2096,10 @@ class ClaudeContextUsageState {
|
||||
return this.createUsageUpdatedEvent(usedTokens);
|
||||
}
|
||||
|
||||
buildResultUsage(message: SDKResultMessage, modelUsage: unknown): AgentUsage | undefined {
|
||||
buildResultUsage(
|
||||
message: SDKResultMessage,
|
||||
modelId: string | null | undefined,
|
||||
): AgentUsage | undefined {
|
||||
try {
|
||||
if (!message.usage) {
|
||||
return undefined;
|
||||
@@ -2062,7 +2111,7 @@ class ClaudeContextUsageState {
|
||||
totalCostUsd: message.total_cost_usd,
|
||||
};
|
||||
|
||||
const modelContextWindowMaxTokens = this.recordModelUsage(modelUsage ?? message.modelUsage);
|
||||
const modelContextWindowMaxTokens = this.recordModelUsage(message.modelUsage, modelId);
|
||||
if (this.contextWindowMaxTokens !== undefined) {
|
||||
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
|
||||
} else if (modelContextWindowMaxTokens !== undefined) {
|
||||
@@ -2729,6 +2778,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!pending) {
|
||||
throw new Error(`No pending permission request with id '${requestId}'`);
|
||||
}
|
||||
|
||||
// Before the delete below, so a rejected answer leaves the request pending
|
||||
// and answerable rather than consuming it for nothing. AgentManager only
|
||||
// drops its own copy once this resolves, so both maps survive the throw.
|
||||
assertClaudeQuestionAnswerDeliverable(pending.request, response);
|
||||
|
||||
this.pendingPermissions.delete(requestId);
|
||||
pending.cleanup?.();
|
||||
|
||||
@@ -4696,7 +4751,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
message: Extract<SDKMessage, { type: "result" }>,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
const usage = this.convertUsage(message, message.modelUsage);
|
||||
const usage = this.convertUsage(message);
|
||||
if (message.subtype === "success") {
|
||||
events.push(...this.sidechainTracker.finishAll("completed"));
|
||||
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
||||
@@ -4878,8 +4933,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return null;
|
||||
}
|
||||
|
||||
private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
|
||||
return this.contextUsage.buildResultUsage(message, modelUsage);
|
||||
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
|
||||
return this.contextUsage.buildResultUsage(message, this.lastRuntimeModel ?? this.config.model);
|
||||
}
|
||||
|
||||
private handlePermissionRequest: CanUseTool = async (
|
||||
@@ -6608,6 +6663,66 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
|
||||
|
||||
/**
|
||||
* The `custom-title` record is re-emitted every time the session is saved, so
|
||||
* the current name is the last usable one in the file. Searching backwards finds it without
|
||||
* walking the transcript, which matters because sessions reach tens of
|
||||
* megabytes.
|
||||
*/
|
||||
function readLastClaudeCustomTitle(content: string): string | null {
|
||||
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
|
||||
let at = content.lastIndexOf(needle);
|
||||
while (at >= 0) {
|
||||
const lineStart = content.lastIndexOf("\n", at) + 1;
|
||||
const lineEnd = content.indexOf("\n", at);
|
||||
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line.trim());
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
const entry = toObjectRecord(parsed);
|
||||
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||
const title = normalizeClaudeSessionTitle(
|
||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||
);
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
// The needle also matches a prompt that quotes it, and a record can carry no
|
||||
// usable name, so keep looking backwards for one that does.
|
||||
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error;
|
||||
}
|
||||
|
||||
async function readClaudeRenamedSessionTitle(input: {
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
configDir: string;
|
||||
}): Promise<string | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8");
|
||||
} catch (error) {
|
||||
// A session with no transcript on disk has no rename to carry over. Every
|
||||
// other read failure is a real fault and stays explicit.
|
||||
if (isErrnoException(error) && error.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return readLastClaudeCustomTitle(content);
|
||||
}
|
||||
|
||||
function normalizeImportablePromptPreview(text: string): string | null {
|
||||
const normalized = text.trim().replace(/\s+/g, " ");
|
||||
if (!normalized) return null;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { CanUseTool, Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import type { ClaudeQueryFactory } from "./query.js";
|
||||
import type { AgentPermissionResponse } from "../../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
|
||||
// The SDK dependency receives the same permission callback as a real Claude process.
|
||||
// Keep its stream open until close; permission state belongs entirely to the session.
|
||||
function createSdkFake() {
|
||||
let canUseTool: CanUseTool | undefined;
|
||||
let finish!: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const queryFactory: ClaudeQueryFactory = ({ options }) => {
|
||||
canUseTool = options.canUseTool;
|
||||
return {
|
||||
close: finish,
|
||||
return: async () => {
|
||||
finish();
|
||||
return { value: undefined, done: true };
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
next: async (): Promise<IteratorResult<SDKMessage, void>> => {
|
||||
await finished;
|
||||
return { value: undefined, done: true };
|
||||
},
|
||||
};
|
||||
},
|
||||
} as Query;
|
||||
};
|
||||
return {
|
||||
queryFactory,
|
||||
ask: (input: Record<string, unknown>) => {
|
||||
if (!canUseTool) throw new Error("SDK permission callback was not configured");
|
||||
return canUseTool("AskUserQuestion", input, {
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "question-tool-call",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const questionInput = {
|
||||
questions: [{ question: "Which colour?", header: "Colour", options: [], multiSelect: false }],
|
||||
};
|
||||
|
||||
const invalidResponses: Array<[string, AgentPermissionResponse]> = [
|
||||
["selectedActionId", { behavior: "allow", selectedActionId: "Viridian" }],
|
||||
["missing answers", { behavior: "allow", updatedInput: {} }],
|
||||
["unknown question", { behavior: "allow", updatedInput: { answers: { Hue: "Viridian" } } }],
|
||||
["blank answer", { behavior: "allow", updatedInput: { answers: { Colour: " " } } }],
|
||||
["non-string answer", { behavior: "allow", updatedInput: { answers: { Colour: 1 } } }],
|
||||
[
|
||||
"empty echoed questions",
|
||||
{ behavior: "allow", updatedInput: { questions: [], answers: { Colour: "Viridian" } } },
|
||||
],
|
||||
];
|
||||
|
||||
describe("Claude question permission responses", () => {
|
||||
test.each(invalidResponses)(
|
||||
"rejects %s and delivers a corrected retry",
|
||||
async (_label, response) => {
|
||||
const sdk = createSdkFake();
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: sdk.queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
|
||||
try {
|
||||
await session.startTurn("Ask which colour");
|
||||
const answer = sdk.ask(questionInput);
|
||||
void answer.catch(() => undefined);
|
||||
const request = session.getPendingPermissions()[0]!;
|
||||
expect(request.kind).toBe("question");
|
||||
await expect(session.respondToPermission(request.id, response)).rejects.toMatchObject({
|
||||
code: "question_answer_required",
|
||||
requestId: request.id,
|
||||
});
|
||||
expect(session.getPendingPermissions()).toEqual([request]);
|
||||
await session.respondToPermission(request.id, {
|
||||
behavior: "allow",
|
||||
updatedInput: { answers: { Colour: "Viridian" } },
|
||||
});
|
||||
await expect(answer).resolves.toMatchObject({
|
||||
behavior: "allow",
|
||||
updatedInput: { answers: { "Which colour?": "Viridian" } },
|
||||
});
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["header", questionInput, { Colour: "Viridian" }, { "Which colour?": "Viridian" }],
|
||||
["question text", questionInput, { "Which colour?": "Ochre" }, { "Which colour?": "Ochre" }],
|
||||
[
|
||||
"padded question text",
|
||||
{ questions: [{ question: " Which colour? ", header: " Colour " }] },
|
||||
{ " Which colour? ": "Ochre" },
|
||||
{ " Which colour? ": "Ochre" },
|
||||
],
|
||||
[
|
||||
"padded header",
|
||||
{ questions: [{ question: " Which colour? ", header: " Colour " }] },
|
||||
{ " Colour ": "Viridian" },
|
||||
{ " Which colour? ": "Viridian" },
|
||||
],
|
||||
[
|
||||
"partial answer",
|
||||
{ questions: [...questionInput.questions, { question: "Ship it?", header: "Ship" }] },
|
||||
{ Ship: "Yes" },
|
||||
{ "Ship it?": "Yes" },
|
||||
],
|
||||
])("delivers answers keyed by %s", async (_label, input, answers, expected) => {
|
||||
const sdk = createSdkFake();
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: sdk.queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
|
||||
try {
|
||||
await session.startTurn("Ask which colour");
|
||||
const answer = sdk.ask(input);
|
||||
void answer.catch(() => undefined);
|
||||
const request = session.getPendingPermissions()[0]!;
|
||||
await session.respondToPermission(request.id, {
|
||||
behavior: "allow",
|
||||
updatedInput: { answers },
|
||||
});
|
||||
await expect(answer).resolves.toMatchObject({
|
||||
behavior: "allow",
|
||||
updatedInput: { answers: expected },
|
||||
});
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("allows denying a question without answers", async () => {
|
||||
const sdk = createSdkFake();
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: sdk.queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
|
||||
try {
|
||||
await session.startTurn("Ask which colour");
|
||||
const answer = sdk.ask(questionInput);
|
||||
void answer.catch(() => undefined);
|
||||
const request = session.getPendingPermissions()[0]!;
|
||||
await session.respondToPermission(request.id, { behavior: "deny", message: "No answer" });
|
||||
await expect(answer).resolves.toMatchObject({ behavior: "deny", message: "No answer" });
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
describe("GenericACPAgentClient slash commands", () => {
|
||||
test("lists commands an agent advertises right after session/new", async () => {
|
||||
test("preserves argument hints in commands advertised right after session/new", async () => {
|
||||
await withFakeACPAgent("commands-after-session-new", async (command, cwd) => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
@@ -17,9 +17,17 @@ describe("GenericACPAgentClient slash commands", () => {
|
||||
});
|
||||
const session = await client.createSession({ provider: "acp", cwd });
|
||||
try {
|
||||
await expect(session.listCommands?.()).resolves.toEqual([
|
||||
{ name: "review", description: "Review the diff", argumentHint: "", kind: "command" },
|
||||
]);
|
||||
const commands = [
|
||||
{
|
||||
name: "review",
|
||||
description: "Review the diff",
|
||||
argumentHint: "query",
|
||||
kind: "command",
|
||||
},
|
||||
{ name: "status", description: "Show status", argumentHint: "", kind: "command" },
|
||||
];
|
||||
await expect(session.listCommands?.()).resolves.toEqual(commands);
|
||||
await expect(session.listCommands?.()).resolves.toEqual(commands);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
@@ -91,7 +99,10 @@ rl.on("line", (line) => {
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "review", description: "Review the diff" }],
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review the diff", input: { hint: "query" } },
|
||||
{ name: "status", description: "Show status" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -157,15 +157,19 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => {
|
||||
chunkSize: 5,
|
||||
});
|
||||
|
||||
const uploadId = result.file?.id ?? "";
|
||||
expect(uploadId).toMatch(
|
||||
/^upload_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
requestId: "req-upload-e2e",
|
||||
file: {
|
||||
type: "uploaded_file",
|
||||
id: "upload_req-upload-e2e",
|
||||
id: uploadId,
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
path: path.join(daemon.paseoHome, "uploads", "upload_req-upload-e2e", "notes.txt"),
|
||||
path: path.join(daemon.paseoHome, "uploads", uploadId, "notes.txt"),
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
@@ -85,3 +85,76 @@ test.each(["uncommitted", "base", "commit"] as const)(
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test.each(
|
||||
(["uncommitted", "base"] as const).flatMap((mode) =>
|
||||
(["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })),
|
||||
),
|
||||
)(
|
||||
"$mode $shape diff includes hunks from a repository subdirectory",
|
||||
async ({ mode, shape }) => {
|
||||
const ctx = await createDaemonTestContext();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
|
||||
|
||||
try {
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Test");
|
||||
git(cwd, "config", "user.email", "test@example.com");
|
||||
git(cwd, "config", "core.autocrlf", "false");
|
||||
const subdirectory = join(cwd, "sub");
|
||||
mkdirSync(subdirectory);
|
||||
const unchangedLines = "unchanged\n".repeat(10);
|
||||
writeFileSync(join(subdirectory, "normal.txt"), `before\n${unchangedLines}`);
|
||||
if (shape === "batch") {
|
||||
writeFileSync(join(cwd, "sibling.txt"), "old sibling\n");
|
||||
}
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
||||
if (mode === "base") {
|
||||
git(cwd, "checkout", "-b", "feature");
|
||||
}
|
||||
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
|
||||
if (shape === "rename") {
|
||||
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
|
||||
}
|
||||
writeFileSync(join(subdirectory, filename), `after\n${unchangedLines}`);
|
||||
if (shape === "batch") {
|
||||
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
|
||||
}
|
||||
if (mode === "base") {
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
||||
}
|
||||
|
||||
const compare = { mode, baseRef: "main" };
|
||||
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
|
||||
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
|
||||
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
|
||||
const liveDiff = await subscription.ready;
|
||||
await subscription.release();
|
||||
|
||||
expect(rootDiff.error).toBeNull();
|
||||
expect(subdirectoryDiff.error).toBeNull();
|
||||
expect(liveDiff.error).toBeNull();
|
||||
expect(liveDiff.files).toEqual(rootDiff.files);
|
||||
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
|
||||
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
|
||||
expect(file).toMatchObject({
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
|
||||
});
|
||||
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "remove", content: "before" }),
|
||||
expect.objectContaining({ type: "add", content: "after" }),
|
||||
]),
|
||||
);
|
||||
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
@@ -67,6 +67,7 @@ describe("daemon E2E - Claude history stored under another project folder", () =
|
||||
content: "reply from the original folder",
|
||||
},
|
||||
},
|
||||
{ type: "custom-title", customTitle: "Renamed moved session", sessionId: SESSION_ID },
|
||||
]
|
||||
.map((entry) => `${JSON.stringify(entry)}\n`)
|
||||
.join(""),
|
||||
@@ -114,6 +115,7 @@ describe("daemon E2E - Claude history stored under another project folder", () =
|
||||
sessionId: SESSION_ID,
|
||||
cwd: agentCwd,
|
||||
});
|
||||
expect(agent.title).toBe("Renamed moved session");
|
||||
await stopDaemon();
|
||||
|
||||
const restartedClient = await startDaemon();
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR"
|
||||
cwd,
|
||||
message: { role: "assistant", content: "reply from the custom config dir" },
|
||||
},
|
||||
{ type: "custom-title", customTitle: "Renamed provider session", sessionId: SESSION_ID },
|
||||
]
|
||||
.map((entry) => `${JSON.stringify(entry)}\n`)
|
||||
.join(""),
|
||||
@@ -111,6 +112,7 @@ describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR"
|
||||
sessionId: SESSION_ID,
|
||||
cwd,
|
||||
});
|
||||
expect(agent.title).toBe("Renamed provider session");
|
||||
await stopDaemon();
|
||||
|
||||
const restartedClient = await startDaemon();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
readdirSync,
|
||||
readlinkSync,
|
||||
} from "fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -100,7 +101,9 @@ describe("checkout discard", () => {
|
||||
}
|
||||
} finally {
|
||||
// afterEach shuts down the daemon and its repository observers first.
|
||||
onTestFinished(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
onTestFinished(() =>
|
||||
rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -124,15 +124,12 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await client.getProvidersSnapshot({ cwd });
|
||||
return snapshot.entries.find((entry) => entry.provider === "antigravity")?.status;
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.toBe("ready");
|
||||
// Snapshot reads start discovery again; await one refresh before checking readiness.
|
||||
await client.refreshProvidersSnapshot({ cwd, providers: ["antigravity"] });
|
||||
const snapshot = await client.getProvidersSnapshot({ cwd });
|
||||
expect(snapshot.entries.find((entry) => entry.provider === "antigravity")?.status).toBe(
|
||||
"ready",
|
||||
);
|
||||
const agent = await client.createAgent({
|
||||
provider: "antigravity",
|
||||
cwd,
|
||||
|
||||
@@ -5,6 +5,44 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createTestPaseoDaemon, DaemonClient } from "./test-utils/index.js";
|
||||
|
||||
test("a rename-only commit returns zero added or deleted lines through the client", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "commit-rename-diff-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const daemon = await createTestPaseoDaemon({ agentClients: {} });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
writeFileSync(join(cwd, "old.txt"), "same\n");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
||||
git("mv", "old.txt", "new.txt");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "rename");
|
||||
const sha = git("rev-parse", "HEAD");
|
||||
|
||||
await client.connect();
|
||||
const history = await client.listCheckoutCommits(cwd);
|
||||
expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([
|
||||
{ path: "new.txt", additions: 0, deletions: 0, status: "renamed" },
|
||||
]);
|
||||
expect(await client.getCommitFileDiff(cwd, sha, "new.txt")).toEqual({
|
||||
file: {
|
||||
path: "new.txt",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"checkout and commit diffs preserve filenames that Git quotes",
|
||||
async () => {
|
||||
|
||||
@@ -169,3 +169,47 @@ test("a restored public SDK timeline leaves missed history to the consumer", asy
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("active directory observers agree on creation and workspace archive across clients", async () => {
|
||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||
const actor = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
||||
const observer = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.8.0",
|
||||
});
|
||||
const directory = new Set<string>();
|
||||
try {
|
||||
await actor.connect();
|
||||
await observer.connect();
|
||||
const subscription = observer.observeAgents({
|
||||
scope: "active",
|
||||
sync: {},
|
||||
includeBackground: true,
|
||||
});
|
||||
subscription.subscribe({
|
||||
snapshot: () => {},
|
||||
update: (message) => {
|
||||
if (message.type !== "agent_update") return;
|
||||
if (message.payload.kind === "upsert") directory.add(message.payload.agent.id);
|
||||
else directory.delete(message.payload.agentId);
|
||||
},
|
||||
});
|
||||
await subscription.ready;
|
||||
const agent = await actor.createAgent({
|
||||
provider: "codex",
|
||||
cwd: daemon.staticDir,
|
||||
title: "Cross-client agent",
|
||||
});
|
||||
await expect.poll(() => directory.has(agent.id)).toBe(true);
|
||||
const beforeArchive = await observer.fetchAgents({ scope: "active", sync: {} });
|
||||
expect(beforeArchive.entries.map((entry) => entry.agent.id)).toContain(agent.id);
|
||||
await actor.archiveWorkspace(agent.workspaceId!);
|
||||
const afterArchive = await observer.fetchAgents({ scope: "active", sync: {} });
|
||||
expect(afterArchive.entries.map((entry) => entry.agent.id)).not.toContain(agent.id);
|
||||
await expect.poll(() => directory.has(agent.id)).toBe(false);
|
||||
} finally {
|
||||
await actor.close();
|
||||
await observer.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { ForgeService } from "../services/forge-service.js";
|
||||
import { buildForkLocalBranchName } from "../utils/change-request-checkout.js";
|
||||
import {
|
||||
CheckoutSourceForgeMismatchError,
|
||||
MissingCheckoutTargetError,
|
||||
@@ -32,14 +33,8 @@ function createResolverHarness(overrides?: {
|
||||
defaultCheckoutRefs: ({ changeRequestNumber }) => [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
],
|
||||
buildPrLocalBranchName: ({ headRef, checkoutTarget }) => {
|
||||
const normalized = checkoutTarget.headOwnerLogin?.trim().toLowerCase() ?? "";
|
||||
const owner =
|
||||
checkoutTarget.isCrossRepository && /^[a-z0-9-]+$/.test(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
return owner ? `${owner}/${headRef}` : headRef;
|
||||
},
|
||||
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
|
||||
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
|
||||
supportsCrossRepoCheckoutWithoutRefs: true,
|
||||
}
|
||||
: {};
|
||||
@@ -182,6 +177,28 @@ describe("resolveWorktreeCreationIntent", () => {
|
||||
expect(deps.headRefLookups).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not track origin for a same-repo head with no origin branch", async () => {
|
||||
const deps = createResolverHarness();
|
||||
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
|
||||
number: 42,
|
||||
baseRefName: "main",
|
||||
headRefName: "ford/towel",
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: false,
|
||||
headRefKind: "pull-ref",
|
||||
});
|
||||
|
||||
const intent = await resolveWorktreeCreationIntent(
|
||||
{ action: "checkout", githubPrNumber: 42 },
|
||||
repoRoot,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(intent).not.toHaveProperty("trackOriginHead");
|
||||
});
|
||||
|
||||
test("configures the contributor remote for fork PR targets", async () => {
|
||||
const deps = createResolverHarness();
|
||||
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
|
||||
@@ -211,6 +228,29 @@ describe("resolveWorktreeCreationIntent", () => {
|
||||
expect(deps.headRefLookups).toEqual([]);
|
||||
});
|
||||
|
||||
test("prefixes the pr number when the fork was deleted", async () => {
|
||||
const deps = createResolverHarness();
|
||||
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
|
||||
number: 42,
|
||||
baseRefName: "main",
|
||||
headRefName: "towel",
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: true,
|
||||
});
|
||||
|
||||
const intent = await resolveWorktreeCreationIntent(
|
||||
{ action: "checkout", githubPrNumber: 42 },
|
||||
repoRoot,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(intent).toMatchObject({ headRef: "towel", localBranchName: "pr-42/towel" });
|
||||
expect(intent).not.toHaveProperty("pushRemoteUrl");
|
||||
expect(intent).not.toHaveProperty("trackOriginHead");
|
||||
});
|
||||
|
||||
test("uses an explicit PR head ref without calling GitHub", async () => {
|
||||
const deps = createResolverHarness();
|
||||
|
||||
@@ -293,6 +333,105 @@ describe("resolveWorktreeCreationIntent", () => {
|
||||
expect(deps.headRefLookups).toEqual([]);
|
||||
});
|
||||
|
||||
test("names the local branch pr-<number> for a gitea pull-ref-only head", async () => {
|
||||
const deps = createResolverHarness({
|
||||
forge: "gitea",
|
||||
forgeService: {
|
||||
defaultCheckoutRefs: ({ changeRequestNumber }) => [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
],
|
||||
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
|
||||
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
|
||||
getPullRequestCheckoutTarget: async ({ number }) => ({
|
||||
number,
|
||||
baseRefName: "main",
|
||||
headRefName: `refs/pull/${number}/head`,
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: false,
|
||||
headRefKind: "pull-ref",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const intent = await resolveWorktreeCreationIntent(
|
||||
{ action: "checkout", checkoutSource: { kind: "change_request", number: 3 } },
|
||||
repoRoot,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(intent).toMatchObject({ localBranchName: "pr-3" });
|
||||
expect(intent).not.toHaveProperty("trackOriginHead");
|
||||
});
|
||||
|
||||
test("names the local branch owner/pr-<number> for a gitea fork pull-ref-only head", async () => {
|
||||
const deps = createResolverHarness({
|
||||
forge: "gitea",
|
||||
forgeService: {
|
||||
defaultCheckoutRefs: ({ changeRequestNumber }) => [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
],
|
||||
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
|
||||
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
|
||||
supportsCrossRepoCheckoutWithoutRefs: true,
|
||||
getPullRequestCheckoutTarget: async ({ number }) => ({
|
||||
number,
|
||||
baseRefName: "main",
|
||||
headRefName: `refs/pull/${number}/head`,
|
||||
headOwnerLogin: "contributor",
|
||||
headRepositorySshUrl: "git@gitea.test:contributor/repo.git",
|
||||
headRepositoryUrl: "https://gitea.test/contributor/repo",
|
||||
isCrossRepository: true,
|
||||
headRefKind: "pull-ref",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const intent = await resolveWorktreeCreationIntent(
|
||||
{ action: "checkout", checkoutSource: { kind: "change_request", number: 3 } },
|
||||
repoRoot,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(intent).toMatchObject({ localBranchName: "contributor/pr-3" });
|
||||
});
|
||||
|
||||
test("does not double the pr number for a gitea deleted fork's pull-ref-only head", async () => {
|
||||
const deps = createResolverHarness({
|
||||
forge: "gitea",
|
||||
forgeService: {
|
||||
defaultCheckoutRefs: ({ changeRequestNumber }) => [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
],
|
||||
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
|
||||
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
|
||||
supportsCrossRepoCheckoutWithoutRefs: true,
|
||||
getPullRequestCheckoutTarget: async ({ number }) => ({
|
||||
number,
|
||||
baseRefName: "main",
|
||||
headRefName: `refs/pull/${number}/head`,
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: true,
|
||||
headRefKind: "pull-ref",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const intent = await resolveWorktreeCreationIntent(
|
||||
{ action: "checkout", checkoutSource: { kind: "change_request", number: 3 } },
|
||||
repoRoot,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(intent).toMatchObject({ localBranchName: "pr-3" });
|
||||
});
|
||||
|
||||
test("reports unsupported cross-repository checkout targets without a checkout ref", async () => {
|
||||
const deps = createResolverHarness({
|
||||
forge: "gitea",
|
||||
|
||||
@@ -191,7 +191,8 @@ async function resolvePrCheckoutIntent(
|
||||
}) ?? [{ remoteName: "origin", remoteRef: `refs/heads/${headRef}` }];
|
||||
const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget });
|
||||
const crossRepository = resolveCrossRepositoryFields(checkoutTarget);
|
||||
const trackOriginHead = !checkoutTarget.isCrossRepository;
|
||||
const trackOriginHead =
|
||||
!checkoutTarget.isCrossRepository && checkoutTarget.headRefKind !== "pull-ref";
|
||||
|
||||
return {
|
||||
kind: "checkout-change-request",
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface PullRequestCheckoutTarget {
|
||||
headRepositorySshUrl: string | null;
|
||||
headRepositoryUrl: string | null;
|
||||
isCrossRepository: boolean;
|
||||
/** "pull-ref" when headRefName is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */
|
||||
headRefKind?: "branch" | "pull-ref";
|
||||
}
|
||||
|
||||
export interface PullRequestCheckoutRef {
|
||||
@@ -441,9 +443,9 @@ export interface ForgeService {
|
||||
getPullRequestCheckoutTarget(options: GetPullRequestOptions): Promise<PullRequestCheckoutTarget>;
|
||||
/**
|
||||
* Refs to fetch for a change-request checkout when the resolved checkout
|
||||
* target carries none. Adapters that expose a universal change-request head
|
||||
* ref (GitHub's refs/pull/N/head) return it here; others let the shell fall
|
||||
* back to the head branch.
|
||||
* target carries none. Adapters with a universal change-request head ref
|
||||
* (refs/pull/N/head) return it here; others let the shell fall back to the
|
||||
* head branch.
|
||||
*/
|
||||
defaultCheckoutRefs?(params: {
|
||||
changeRequestNumber: number;
|
||||
@@ -451,7 +453,7 @@ export interface ForgeService {
|
||||
}): PullRequestCheckoutRef[];
|
||||
/**
|
||||
* Local branch name for a checked-out change request when the adapter
|
||||
* disambiguates cross-repository heads (GitHub prefixes the fork owner).
|
||||
* disambiguates cross-repository heads (prefixes the fork owner).
|
||||
* Returns undefined to keep the head ref name as-is.
|
||||
*/
|
||||
buildPrLocalBranchName?(params: {
|
||||
|
||||
@@ -2297,11 +2297,13 @@ describe("createGiteaService", () => {
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
|
||||
{ remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" },
|
||||
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
|
||||
],
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: false,
|
||||
headRefKind: "branch",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2332,17 +2334,215 @@ describe("createGiteaService", () => {
|
||||
headRefName: "feat/sample-change",
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
|
||||
{ remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" },
|
||||
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
|
||||
],
|
||||
headOwnerLogin: "contributor",
|
||||
headRepositorySshUrl: "git@gitea.com:contributor/sample-repo.git",
|
||||
headRepositoryUrl: "https://gitea.com/contributor/sample-repo",
|
||||
isCrossRepository: true,
|
||||
headRefKind: "branch",
|
||||
});
|
||||
|
||||
expect(calls).toContainEqual(["api", "repos/example-user/sample-repo/pulls/5"]);
|
||||
});
|
||||
|
||||
it("skips the origin branch fallback and tracking for an AGit pull request", async () => {
|
||||
const { service } = makeService((args) =>
|
||||
args[0] === "api"
|
||||
? ok(
|
||||
JSON.stringify({
|
||||
flow: 1,
|
||||
head: { ref: "refs/pull/5/head", repo: { id: 1 } },
|
||||
base: { repo: { id: 1 } },
|
||||
}),
|
||||
)
|
||||
: ok(JSON.stringify(STATUS_PR_VIEW)),
|
||||
);
|
||||
|
||||
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
|
||||
|
||||
expect(target).toMatchObject({
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
|
||||
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
|
||||
],
|
||||
isCrossRepository: false,
|
||||
headRefKind: "pull-ref",
|
||||
});
|
||||
});
|
||||
|
||||
it("names the local branch pr-<number> end to end for an agit pull request", async () => {
|
||||
// the tea CLI reports head as the raw pull ref too when there's no real
|
||||
// branch, same as the Forgejo API's head.ref
|
||||
const { service } = makeService((args) =>
|
||||
args[0] === "api"
|
||||
? ok(
|
||||
JSON.stringify({
|
||||
flow: 1,
|
||||
head: { ref: "refs/pull/5/head", repo: { id: 1 } },
|
||||
base: { repo: { id: 1 } },
|
||||
}),
|
||||
)
|
||||
: ok(JSON.stringify({ ...STATUS_PR_VIEW, head: "refs/pull/5/head" })),
|
||||
);
|
||||
|
||||
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
|
||||
const localBranchName = service.buildPrLocalBranchName?.({
|
||||
headRef: target.headRefName,
|
||||
checkoutTarget: target,
|
||||
});
|
||||
|
||||
expect(localBranchName).toBe("pr-5");
|
||||
});
|
||||
|
||||
it("skips the origin branch fallback when the head branch was deleted", async () => {
|
||||
const { service } = makeService((args) =>
|
||||
args[0] === "api"
|
||||
? ok(
|
||||
JSON.stringify({
|
||||
head: { ref: "refs/pull/5/head", repo: { id: 1 } },
|
||||
base: { repo: { id: 1 } },
|
||||
}),
|
||||
)
|
||||
: ok(JSON.stringify(STATUS_PR_VIEW)),
|
||||
);
|
||||
|
||||
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
|
||||
|
||||
expect(target).toMatchObject({
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
|
||||
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
|
||||
],
|
||||
isCrossRepository: false,
|
||||
headRefKind: "pull-ref",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a pull request from a deleted fork cross-repository", async () => {
|
||||
const { service } = makeService((args) =>
|
||||
args[0] === "api"
|
||||
? ok(JSON.stringify({ head: { repo: null }, base: { repo: { id: 1 } } }))
|
||||
: ok(JSON.stringify(STATUS_PR_VIEW)),
|
||||
);
|
||||
|
||||
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
|
||||
|
||||
expect(target).toMatchObject({
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
|
||||
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
|
||||
],
|
||||
headOwnerLogin: null,
|
||||
isCrossRepository: true,
|
||||
headRefKind: "branch",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the origin branch fallback when the server sends no head ref", async () => {
|
||||
const { service } = makeService((args) =>
|
||||
args[0] === "api"
|
||||
? ok(JSON.stringify({ head: { repo: { id: 1 } }, base: { repo: { id: 1 } } }))
|
||||
: ok(JSON.stringify(STATUS_PR_VIEW)),
|
||||
);
|
||||
|
||||
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
|
||||
|
||||
expect(target.checkoutRefs).toContainEqual({
|
||||
remoteName: "origin",
|
||||
remoteRef: "refs/heads/feat/sample-change",
|
||||
});
|
||||
expect(target.headRefKind).toBe("branch");
|
||||
});
|
||||
|
||||
it("prefixes the local branch name with the fork owner for a cross-repository checkout", () => {
|
||||
const { service } = makeService(() => ok(""));
|
||||
|
||||
const localBranchName = service.buildPrLocalBranchName?.({
|
||||
headRef: "patch-1",
|
||||
checkoutTarget: {
|
||||
number: 5,
|
||||
baseRefName: "main",
|
||||
headRefName: "patch-1",
|
||||
headOwnerLogin: "contributor",
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(localBranchName).toBe("contributor/patch-1");
|
||||
});
|
||||
|
||||
it("names the local branch pr-<number> for a same-repo pull-ref-only head", () => {
|
||||
const { service } = makeService(() => ok(""));
|
||||
|
||||
const localBranchName = service.buildPrLocalBranchName?.({
|
||||
headRef: "refs/pull/5/head",
|
||||
checkoutTarget: {
|
||||
number: 5,
|
||||
baseRefName: "main",
|
||||
headRefName: "refs/pull/5/head",
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: false,
|
||||
headRefKind: "pull-ref",
|
||||
},
|
||||
});
|
||||
|
||||
expect(localBranchName).toBe("pr-5");
|
||||
});
|
||||
|
||||
it("prefixes pr-<number> with the fork owner for a pull-ref-only head", () => {
|
||||
const { service } = makeService(() => ok(""));
|
||||
|
||||
const localBranchName = service.buildPrLocalBranchName?.({
|
||||
headRef: "refs/pull/5/head",
|
||||
checkoutTarget: {
|
||||
number: 5,
|
||||
baseRefName: "main",
|
||||
headRefName: "refs/pull/5/head",
|
||||
headOwnerLogin: "contributor",
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: true,
|
||||
headRefKind: "pull-ref",
|
||||
},
|
||||
});
|
||||
|
||||
expect(localBranchName).toBe("contributor/pr-5");
|
||||
});
|
||||
|
||||
it("does not double the pr number for a deleted fork's pull-ref-only head", () => {
|
||||
const { service } = makeService(() => ok(""));
|
||||
|
||||
const localBranchName = service.buildPrLocalBranchName?.({
|
||||
headRef: "refs/pull/5/head",
|
||||
checkoutTarget: {
|
||||
number: 5,
|
||||
baseRefName: "main",
|
||||
headRefName: "refs/pull/5/head",
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: true,
|
||||
headRefKind: "pull-ref",
|
||||
},
|
||||
});
|
||||
|
||||
expect(localBranchName).toBe("pr-5");
|
||||
});
|
||||
|
||||
it("returns the pull head ref for default checkout refs", () => {
|
||||
const { service } = makeService(() => ok(""));
|
||||
|
||||
expect(service.defaultCheckoutRefs?.({ changeRequestNumber: 5, headRef: "patch-1" })).toEqual([
|
||||
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
|
||||
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a pull request and parses the resulting URL and index", async () => {
|
||||
const { service, calls } = makeService((args) => {
|
||||
if (args[0] === "pr" && args[1] === "create") {
|
||||
|
||||
@@ -5,6 +5,10 @@ import pLimit from "p-limit";
|
||||
import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import { findExecutable } from "../executable-resolution/executable-resolution.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import {
|
||||
buildForkLocalBranchName,
|
||||
buildPullHeadCheckoutRefs,
|
||||
} from "../utils/change-request-checkout.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
import {
|
||||
createCachedCliPathResolver,
|
||||
@@ -75,6 +79,8 @@ const CURRENT_PR_HEAD_SHA_CACHE_TTL_MS = 30_000;
|
||||
// the sweep to the 250 most recent Actions tasks.
|
||||
const GITEA_ACTION_PAGE_LIMIT = 50;
|
||||
const GITEA_ACTION_MAX_PAGES = 5;
|
||||
// PullRequestFlowAGit in forgejo models/issues/pull.go
|
||||
const GITEA_PULL_REQUEST_FLOW_AGIT = 1;
|
||||
|
||||
/**
|
||||
* Fields requested from `tea pr list -o json`. tea's default field set omits the
|
||||
@@ -225,8 +231,12 @@ const GiteaPullRequestRepoSchema = z
|
||||
|
||||
const GiteaPullRequestApiSchema = z
|
||||
.object({
|
||||
flow: z.number().optional(),
|
||||
head: z
|
||||
.object({ repo: GiteaPullRequestRepoSchema.nullable().optional() })
|
||||
.object({
|
||||
ref: z.string().optional(),
|
||||
repo: GiteaPullRequestRepoSchema.nullable().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
base: z
|
||||
@@ -1435,17 +1445,30 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
return runJsonParse(args, runOptions, stdout, schema);
|
||||
}
|
||||
|
||||
function resolveHeadRefKind(
|
||||
pr: z.infer<typeof GiteaPullRequestApiSchema>,
|
||||
number: number,
|
||||
): "branch" | "pull-ref" {
|
||||
// head.ref only becomes the branch name when the branch still exists,
|
||||
// agit PRs and deleted branches keep the pull ref
|
||||
const isPullRefOnly =
|
||||
pr.flow === GITEA_PULL_REQUEST_FLOW_AGIT || pr.head?.ref === `refs/pull/${number}/head`;
|
||||
return isPullRefOnly ? "pull-ref" : "branch";
|
||||
}
|
||||
|
||||
async function resolveForkCheckoutFacts(
|
||||
cwd: string,
|
||||
summary: PullRequestSummary,
|
||||
): Promise<{
|
||||
isCrossRepository: boolean;
|
||||
headRefKind: "branch" | "pull-ref";
|
||||
headOwnerLogin: string | null;
|
||||
headRepositorySshUrl: string | null;
|
||||
headRepositoryUrl: string | null;
|
||||
}> {
|
||||
const sameRepo = {
|
||||
isCrossRepository: false,
|
||||
headRefKind: "branch" as const,
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
@@ -1464,14 +1487,17 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
);
|
||||
const headRepo = pr.head?.repo ?? null;
|
||||
const baseRepo = pr.base?.repo ?? null;
|
||||
if (headRepo?.id == null || baseRepo?.id == null || headRepo.id === baseRepo.id) {
|
||||
return sameRepo;
|
||||
const headRefKind = resolveHeadRefKind(pr, summary.number);
|
||||
if (baseRepo?.id == null || headRepo?.id === baseRepo.id) {
|
||||
return { ...sameRepo, headRefKind };
|
||||
}
|
||||
// headRepo is null when the fork was deleted
|
||||
return {
|
||||
isCrossRepository: true,
|
||||
headOwnerLogin: headRepo.owner?.login ?? null,
|
||||
headRepositorySshUrl: headRepo.ssh_url ?? null,
|
||||
headRepositoryUrl: headRepo.html_url ?? null,
|
||||
headRefKind,
|
||||
headOwnerLogin: headRepo?.owner?.login ?? null,
|
||||
headRepositorySshUrl: headRepo?.ssh_url ?? null,
|
||||
headRepositoryUrl: headRepo?.html_url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1997,17 +2023,29 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
return summary.headRefName;
|
||||
},
|
||||
|
||||
defaultCheckoutRefs({ changeRequestNumber }) {
|
||||
return buildPullHeadCheckoutRefs(changeRequestNumber);
|
||||
},
|
||||
|
||||
buildPrLocalBranchName({ headRef, checkoutTarget }) {
|
||||
return buildForkLocalBranchName({ headRef, ...checkoutTarget });
|
||||
},
|
||||
|
||||
async getPullRequestCheckoutTarget(
|
||||
input: GetPullRequestOptions,
|
||||
): Promise<PullRequestCheckoutTarget> {
|
||||
const summary = await this.getPullRequest(input);
|
||||
const checkoutRefs = [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${summary.number}/head` },
|
||||
];
|
||||
if (summary.headRefName) {
|
||||
const fork = await resolveForkCheckoutFacts(input.cwd, summary);
|
||||
const [originPullRef, upstreamPullRef] = buildPullHeadCheckoutRefs(summary.number);
|
||||
const checkoutRefs = [originPullRef];
|
||||
// for a fork PR a branch with that name on origin belongs to the base repo
|
||||
const headBranchOnOrigin =
|
||||
!fork.isCrossRepository && fork.headRefKind === "branch" && Boolean(summary.headRefName);
|
||||
if (headBranchOnOrigin) {
|
||||
checkoutRefs.push({ remoteName: "origin", remoteRef: `refs/heads/${summary.headRefName}` });
|
||||
}
|
||||
const fork = await resolveForkCheckoutFacts(input.cwd, summary);
|
||||
// upstream can hold a different PR with the same number, so it goes last
|
||||
checkoutRefs.push(upstreamPullRef);
|
||||
return {
|
||||
number: summary.number,
|
||||
baseRefName: summary.baseRefName,
|
||||
@@ -2017,6 +2055,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
headRepositorySshUrl: fork.headRepositorySshUrl,
|
||||
headRepositoryUrl: fork.headRepositoryUrl,
|
||||
isCrossRepository: fork.isCrossRepository,
|
||||
headRefKind: fork.headRefKind,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -797,6 +797,7 @@ describe("ForgeService", () => {
|
||||
headRepositorySshUrl: "git@github.com:therainisme/paseo.git",
|
||||
headRepositoryUrl: "https://github.com/therainisme/paseo",
|
||||
isCrossRepository: true,
|
||||
headRefKind: "branch",
|
||||
});
|
||||
|
||||
expect(runner.calls).toHaveLength(2);
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
} from "@getpaseo/protocol/git-remote";
|
||||
import { findExecutable } from "../executable-resolution/executable-resolution.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import {
|
||||
buildForkLocalBranchName,
|
||||
buildPullHeadCheckoutRefs,
|
||||
} from "../utils/change-request-checkout.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
import { resolveSshHostname } from "../utils/ssh-hostname.js";
|
||||
import {
|
||||
@@ -2104,17 +2108,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
},
|
||||
|
||||
defaultCheckoutRefs({ changeRequestNumber }) {
|
||||
return [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
];
|
||||
return buildPullHeadCheckoutRefs(changeRequestNumber);
|
||||
},
|
||||
|
||||
buildPrLocalBranchName({ headRef, checkoutTarget }) {
|
||||
const owner = checkoutTarget.isCrossRepository
|
||||
? normalizeGitHubOwnerForBranch(checkoutTarget.headOwnerLogin)
|
||||
: null;
|
||||
return owner ? `${owner}/${headRef}` : headRef;
|
||||
return buildForkLocalBranchName({ headRef, ...checkoutTarget });
|
||||
},
|
||||
|
||||
supportsCrossRepoCheckoutWithoutRefs: true,
|
||||
@@ -2663,11 +2661,6 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
return api;
|
||||
}
|
||||
|
||||
function normalizeGitHubOwnerForBranch(owner: string | null): string | null {
|
||||
const normalized = owner?.trim().toLowerCase() ?? "";
|
||||
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function getGithubStatusFacts(
|
||||
status: PullRequestCommandStatus | null | undefined,
|
||||
): GitHubPullRequestStatusFacts | null {
|
||||
@@ -3529,6 +3522,7 @@ function toPullRequestCheckoutTarget(
|
||||
headRepositorySshUrl: pullRequest.headRepository?.sshUrl || null,
|
||||
headRepositoryUrl: pullRequest.headRepository?.url || null,
|
||||
isCrossRepository: pullRequest.isCrossRepository,
|
||||
headRefKind: "branch",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -477,6 +477,7 @@ describe("createGitLabService", () => {
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: false,
|
||||
headRefKind: "branch",
|
||||
});
|
||||
expect(calls[0]).toEqual(["mr", "view", "14", "-F", "json"]);
|
||||
});
|
||||
|
||||
@@ -1204,6 +1204,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
|
||||
mr.target_project_id !== undefined &&
|
||||
mr.target_project_id !== null &&
|
||||
mr.source_project_id !== mr.target_project_id,
|
||||
headRefKind: "branch",
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildForkLocalBranchName,
|
||||
normalizeForgeOwnerForBranch,
|
||||
} from "./change-request-checkout.js";
|
||||
|
||||
describe("normalizeForgeOwnerForBranch", () => {
|
||||
it("lowercases a valid login", () => {
|
||||
expect(normalizeForgeOwnerForBranch("Contributor")).toBe("contributor");
|
||||
});
|
||||
|
||||
it("allows dots and underscores", () => {
|
||||
expect(normalizeForgeOwnerForBranch("a.b_c")).toBe("a.b_c");
|
||||
});
|
||||
|
||||
it("rejects a login starting with a dash", () => {
|
||||
expect(normalizeForgeOwnerForBranch("-abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a login starting with a dot", () => {
|
||||
expect(normalizeForgeOwnerForBranch(".hidden")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a login containing '..'", () => {
|
||||
expect(normalizeForgeOwnerForBranch("a..b")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a login ending in .lock", () => {
|
||||
expect(normalizeForgeOwnerForBranch("contributor.lock")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a login with a path traversal segment", () => {
|
||||
expect(normalizeForgeOwnerForBranch("../evil")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an empty or null login", () => {
|
||||
expect(normalizeForgeOwnerForBranch(null)).toBeNull();
|
||||
expect(normalizeForgeOwnerForBranch("")).toBeNull();
|
||||
expect(normalizeForgeOwnerForBranch(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildForkLocalBranchName", () => {
|
||||
const fork = { headRef: "patch-1", number: 42, isCrossRepository: true };
|
||||
|
||||
it("keeps the head ref for a same-repository head", () => {
|
||||
expect(
|
||||
buildForkLocalBranchName({ ...fork, isCrossRepository: false, headOwnerLogin: "arthur" }),
|
||||
).toBe("patch-1");
|
||||
});
|
||||
|
||||
it("prefixes a fork head with the lowercased owner", () => {
|
||||
expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: "Arthur" })).toBe("arthur/patch-1");
|
||||
});
|
||||
|
||||
it("falls back to the pr number when the owner is unknown", () => {
|
||||
expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: null })).toBe("pr-42/patch-1");
|
||||
});
|
||||
|
||||
it("falls back to the pr number when the owner is not a safe ref segment", () => {
|
||||
expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: "-zaphod" })).toBe("pr-42/patch-1");
|
||||
});
|
||||
|
||||
// pull-ref-only heads (agit PRs, deleted branches) report headRef as the
|
||||
// literal pull ref, e.g. refs/pull/42/head, not a real branch name
|
||||
it("collapses a same-repository pull-ref-only head to pr-<number>", () => {
|
||||
expect(
|
||||
buildForkLocalBranchName({
|
||||
...fork,
|
||||
isCrossRepository: false,
|
||||
headOwnerLogin: "arthur",
|
||||
headRefKind: "pull-ref",
|
||||
}),
|
||||
).toBe("pr-42");
|
||||
});
|
||||
|
||||
it("prefixes pr-<number> with the owner for a fork pull-ref-only head", () => {
|
||||
expect(
|
||||
buildForkLocalBranchName({ ...fork, headOwnerLogin: "Arthur", headRefKind: "pull-ref" }),
|
||||
).toBe("arthur/pr-42");
|
||||
});
|
||||
|
||||
it("does not double the pr number when the fork owner is unknown and there is no head branch", () => {
|
||||
expect(
|
||||
buildForkLocalBranchName({ ...fork, headOwnerLogin: null, headRefKind: "pull-ref" }),
|
||||
).toBe("pr-42");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PullRequestCheckoutRef } from "../services/forge-service.js";
|
||||
|
||||
// upstream for clones where origin is your fork
|
||||
export function buildPullHeadCheckoutRefs(number: number): PullRequestCheckoutRef[] {
|
||||
return [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${number}/head` },
|
||||
{ remoteName: "upstream", remoteRef: `refs/pull/${number}/head` },
|
||||
];
|
||||
}
|
||||
|
||||
// forgejo username pattern, covers github logins too
|
||||
export function normalizeForgeOwnerForBranch(owner: string | null): string | null {
|
||||
const normalized = owner?.trim().toLowerCase() ?? "";
|
||||
if (!/^[a-z0-9][a-z0-9_.-]*$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
// git refuses .. and .lock, trailing dot too
|
||||
if (normalized.includes("..") || normalized.endsWith(".") || normalized.endsWith(".lock")) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export interface ForkLocalBranchNameInput {
|
||||
headRef: string;
|
||||
number: number;
|
||||
isCrossRepository: boolean;
|
||||
headOwnerLogin: string | null;
|
||||
/** "pull-ref" when headRef is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */
|
||||
headRefKind?: "branch" | "pull-ref";
|
||||
}
|
||||
|
||||
export function buildForkLocalBranchName(params: ForkLocalBranchNameInput): string {
|
||||
const pullRefFallback = `pr-${params.number}`;
|
||||
// headRef is the literal pull ref (e.g. refs/pull/42/head) when there is no
|
||||
// real branch to name after, so use the pr number instead
|
||||
const headSegment = params.headRefKind === "pull-ref" ? pullRefFallback : params.headRef;
|
||||
if (!params.isCrossRepository) {
|
||||
return headSegment;
|
||||
}
|
||||
const owner = normalizeForgeOwnerForBranch(params.headOwnerLogin);
|
||||
if (!owner) {
|
||||
// don't double up pr-<n>/pr-<n> when the owner is also unknown
|
||||
return headSegment === pullRefFallback ? headSegment : `${pullRefFallback}/${headSegment}`;
|
||||
}
|
||||
return `${owner}/${headSegment}`;
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getCommitFileDiff } from "./checkout-git.js";
|
||||
import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js";
|
||||
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -44,6 +45,75 @@ function headSha(repoDir: string): string {
|
||||
}
|
||||
|
||||
describe("getCommitFileDiff", () => {
|
||||
it.each(["new.txt", "with spaces.txt"])("preserves a rename-only commit to %s", async (name) => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "old.txt", "same\n", "initial");
|
||||
git(["mv", "old.txt", name], repoDir);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir);
|
||||
const sha = headSha(repoDir);
|
||||
|
||||
const history = await listCheckoutCommits({ cwd: repoDir });
|
||||
expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([
|
||||
{ path: name, status: "renamed", additions: 0, deletions: 0 },
|
||||
]);
|
||||
expect(await getCommitFileDiff({ cwd: repoDir, sha, path: name })).toEqual({
|
||||
path: name,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["new.txt", "目录.txt"])(
|
||||
"preserves the edits in a committed rename to %s",
|
||||
async (name) => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "old.txt", "same\nkeep\nlast\n", "initial");
|
||||
git(["mv", "old.txt", name], repoDir);
|
||||
writeFileSync(join(repoDir, name), "same\nKEEP\nlast\n");
|
||||
git(["add", "."], repoDir);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", "rename and edit"], repoDir);
|
||||
const sha = headSha(repoDir);
|
||||
|
||||
const history = await listCheckoutCommits({ cwd: repoDir });
|
||||
expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([
|
||||
{ path: name, status: "renamed", additions: 1, deletions: 1 },
|
||||
]);
|
||||
const file = await getCommitFileDiff({ cwd: repoDir, sha, path: name });
|
||||
expect(file).toMatchObject({
|
||||
path: name,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
});
|
||||
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "remove", content: "keep" }),
|
||||
expect.objectContaining({ type: "add", content: "KEEP" }),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")("preserves a rename to a quoted filename", async () => {
|
||||
const repoDir = initRepo();
|
||||
const name = 'new "draft".txt';
|
||||
commitFile(repoDir, "old.txt", "same\n", "initial");
|
||||
git(["mv", "old.txt", name], repoDir);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir);
|
||||
expect(await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: name })).toEqual({
|
||||
path: name,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the parsed diff for a modified file in a commit", async () => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "foo.txt", "a\nb\nc\n", "initial");
|
||||
@@ -78,6 +148,78 @@ describe("getCommitFileDiff", () => {
|
||||
expect(file?.deletions).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["exact", "line1\nline2\nline3\nline4\nline5\nline6\n"],
|
||||
["edited", "line1\nline2\nline3\nline4\nline5\nCHANGED\n"],
|
||||
])("shows an added %s copy as a new file", async (_kind, addedContent) => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "source.txt", "line1\nline2\nline3\nline4\nline5\nline6\n", "initial");
|
||||
commitFile(repoDir, "added.txt", addedContent, "add similar file");
|
||||
const sha = headSha(repoDir);
|
||||
|
||||
const history = await listCheckoutCommits({ cwd: repoDir });
|
||||
expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([
|
||||
{ path: "added.txt", status: "added", additions: 6, deletions: 0 },
|
||||
]);
|
||||
const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" });
|
||||
expect(file).toMatchObject({
|
||||
path: "added.txt",
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: 6,
|
||||
deletions: 0,
|
||||
});
|
||||
expect(
|
||||
file?.hunks
|
||||
.flatMap((hunk) => hunk.lines)
|
||||
.filter((line) => line.type !== "header")
|
||||
.map(({ type, content }) => ({ type, content })),
|
||||
).toEqual(
|
||||
addedContent
|
||||
.trimEnd()
|
||||
.split("\n")
|
||||
.map((content) => ({ type: "add", content })),
|
||||
);
|
||||
});
|
||||
|
||||
it("shares commit rename discovery across concurrent and later file requests", async () => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "old.txt", "same\n", "initial");
|
||||
git(["mv", "old.txt", "new.txt"], repoDir);
|
||||
commitFile(repoDir, "added.txt", "added\n", "rename and add");
|
||||
const sha = headSha(repoDir);
|
||||
|
||||
startGitCommandMetrics();
|
||||
try {
|
||||
const [renamed, added] = await Promise.all([
|
||||
getCommitFileDiff({ cwd: repoDir, sha, path: "new.txt" }),
|
||||
getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" }),
|
||||
]);
|
||||
expect(renamed).toMatchObject({ isNew: false, additions: 0, deletions: 0 });
|
||||
expect(added).toMatchObject({ isNew: true, additions: 1, deletions: 0 });
|
||||
expect(await getCommitFileDiff({ cwd: repoDir, sha, path: "new.txt" })).toEqual(renamed);
|
||||
} finally {
|
||||
const metrics = stopGitCommandMetrics();
|
||||
expect(metrics.commands.filter(({ args }) => args.includes("--name-status"))).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("rediscovers renames when a symbolic commit ref moves", async () => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "old.txt", "same\n", "initial");
|
||||
expect(await getCommitFileDiff({ cwd: repoDir, sha: "HEAD", path: "old.txt" })).toMatchObject({
|
||||
isNew: true,
|
||||
additions: 1,
|
||||
});
|
||||
git(["mv", "old.txt", "new.txt"], repoDir);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir);
|
||||
expect(await getCommitFileDiff({ cwd: repoDir, sha: "HEAD", path: "new.txt" })).toMatchObject({
|
||||
isNew: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for a path not changed in the commit", async () => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "foo.txt", "a\n", "initial");
|
||||
@@ -89,6 +231,59 @@ describe("getCommitFileDiff", () => {
|
||||
expect(file).toBeNull();
|
||||
});
|
||||
|
||||
it("does not follow an unchanged file back to an earlier commit", async () => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "foo.txt", "same\n", "initial");
|
||||
commitFile(repoDir, "other.txt", "other\n", "unrelated change");
|
||||
expect(
|
||||
await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the added file in a root commit and the deleted file in its deletion commit", async () => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "foo.txt", "same\n", "initial");
|
||||
expect(
|
||||
await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }),
|
||||
).toMatchObject({
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
});
|
||||
git(["rm", "foo.txt"], repoDir);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", "delete"], repoDir);
|
||||
expect(
|
||||
await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }),
|
||||
).toMatchObject({
|
||||
isNew: false,
|
||||
isDeleted: true,
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a rename introduced by a merge against its first parent", async () => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "old.txt", "same\n", "initial");
|
||||
git(["checkout", "-b", "feature"], repoDir);
|
||||
git(["mv", "old.txt", "new.txt"], repoDir);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir);
|
||||
git(["checkout", "main"], repoDir);
|
||||
commitFile(repoDir, "main.txt", "main\n", "advance main");
|
||||
git(["merge", "--no-ff", "feature", "-m", "merge feature"], repoDir);
|
||||
expect(
|
||||
await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "new.txt" }),
|
||||
).toMatchObject({
|
||||
path: "new.txt",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a merge commit diff against its first parent", async () => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "README.md", "base\n", "initial");
|
||||
|
||||
@@ -2643,6 +2643,60 @@ export async function listCheckoutCommits({
|
||||
return { baseRef: comparisonBaseRef, commits };
|
||||
}
|
||||
|
||||
const commitRenamesCache = new TTLCache<string, Promise<ReadonlyMap<string, string>>>({
|
||||
max: 100,
|
||||
ttl: 60_000,
|
||||
checkAgeOnGet: true,
|
||||
});
|
||||
|
||||
function getCommitRenames(cwd: string, sha: string): Promise<ReadonlyMap<string, string>> {
|
||||
// Symbolic refs and abbreviated IDs can resolve to a different commit later.
|
||||
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) {
|
||||
return readCommitRenames(cwd, sha);
|
||||
}
|
||||
const key = JSON.stringify([cwd, sha]);
|
||||
const cached = commitRenamesCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const lookup = readCommitRenames(cwd, sha).catch((error: unknown) => {
|
||||
if (commitRenamesCache.get(key) === lookup) {
|
||||
commitRenamesCache.delete(key);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
// Store the pending lookup too, so concurrent file requests share the scan.
|
||||
commitRenamesCache.set(key, lookup);
|
||||
return lookup;
|
||||
}
|
||||
|
||||
// Discover renames across the commit before narrowing the patch to a file.
|
||||
// --follow also detects copies, which would disagree with history's -M-only list.
|
||||
async function readCommitRenames(cwd: string, sha: string): Promise<ReadonlyMap<string, string>> {
|
||||
const result = await runGitCommand(
|
||||
[
|
||||
"show",
|
||||
sha,
|
||||
"--format=",
|
||||
"--diff-merges=first-parent",
|
||||
"--name-status",
|
||||
"-z",
|
||||
"-M",
|
||||
"--diff-filter=R",
|
||||
],
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
if (result.truncated) {
|
||||
throw new Error("Commit renames exceeded the git output limit");
|
||||
}
|
||||
const renames = new Map<string, string>();
|
||||
const fields = result.stdout.split("\0");
|
||||
for (let index = 0; index + 2 < fields.length; index += 3) {
|
||||
renames.set(fields[index + 2]!, fields[index + 1]!);
|
||||
}
|
||||
return renames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the unified diff of a single file as introduced by one commit and
|
||||
* parses it into the same {@link ParsedDiffFile} shape the diff subscription
|
||||
@@ -2665,8 +2719,20 @@ export async function getCommitFileDiff({
|
||||
path: string;
|
||||
}): Promise<ParsedDiffFile | null> {
|
||||
const worktreeRoot = await requireGitWorktreeRoot(cwd);
|
||||
const renames = await getCommitRenames(worktreeRoot, sha);
|
||||
const oldPath = renames.get(path);
|
||||
const paths = oldPath === undefined ? [path] : [oldPath, path];
|
||||
const { stdout } = await runGitCommand(
|
||||
["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path],
|
||||
[
|
||||
"show",
|
||||
sha,
|
||||
"--format=",
|
||||
"--diff-merges=first-parent",
|
||||
"-M",
|
||||
...GIT_PATCH_FORMAT_ARGS,
|
||||
"--",
|
||||
...paths.map((filePath) => `:(literal)${filePath}`),
|
||||
],
|
||||
{
|
||||
cwd: worktreeRoot,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
@@ -2682,8 +2748,8 @@ export async function getCommitFileDiff({
|
||||
getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path),
|
||||
});
|
||||
|
||||
// `--` scopes the diff to a single pathspec, so there is at most one real
|
||||
// entry. Pick by path to drop any stray header-only section the parser emits.
|
||||
// Including the rename source lets Git pair it with the destination. Select
|
||||
// the requested entry if another change also matches the source path.
|
||||
const file = parsedFiles.find((candidate) => candidate.path === path) ?? null;
|
||||
if (!file) {
|
||||
return null;
|
||||
@@ -3423,7 +3489,7 @@ export async function getCheckoutDiff(
|
||||
compare: CheckoutDiffCompare,
|
||||
context?: CheckoutContext,
|
||||
): Promise<CheckoutDiffResult> {
|
||||
await requireGitRepo(cwd);
|
||||
cwd = await requireGitWorktreeRoot(cwd);
|
||||
|
||||
const refsForDiff = await resolveCheckoutDiffRefs(cwd, compare, context);
|
||||
if (!refsForDiff) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
@@ -94,6 +95,64 @@ describe("paseo worktree manager", () => {
|
||||
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ existing: [], preferred: "main/patch-1", expected: "main-1/patch-1" },
|
||||
{ existing: ["main-1"], preferred: "main/patch-1", expected: "main-2/patch-1" },
|
||||
{
|
||||
existing: ["main-1/patch-1"],
|
||||
preferred: "main/patch-1",
|
||||
expected: "main-1/patch-1-2",
|
||||
},
|
||||
{
|
||||
existing: ["bob/patch-1/child"],
|
||||
preferred: "bob/patch-1",
|
||||
expected: "bob/patch-1-1",
|
||||
},
|
||||
])(
|
||||
"checks out a fork when $preferred conflicts with existing branch paths",
|
||||
async ({ existing, preferred, expected }) => {
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync("git", args, { cwd: repoDir, encoding: "utf8", stdio: "pipe" }).trim();
|
||||
git("config", "core.autocrlf", "false");
|
||||
const baseHead = git("rev-parse", "main");
|
||||
for (const branch of existing) git("branch", branch);
|
||||
git("checkout", "-b", "fixture-pr");
|
||||
writeFileSync(join(repoDir, "file.txt"), "from-pr\n");
|
||||
git("add", "file.txt");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "fork patch");
|
||||
const prHead = git("rev-parse", "HEAD");
|
||||
git("checkout", "main");
|
||||
const remote = join(tempDir, "origin.git");
|
||||
git("clone", "--bare", repoDir, remote);
|
||||
git("remote", "add", "origin", remote);
|
||||
git(`--git-dir=${remote}`, "update-ref", "refs/pull/1/head", prHead);
|
||||
git("pack-refs", "--all");
|
||||
|
||||
const result = await createWorktreePrimitive({
|
||||
cwd: repoDir,
|
||||
paseoHome,
|
||||
worktreeSlug: "fork-review",
|
||||
runSetup: false,
|
||||
source: {
|
||||
kind: "checkout-change-request",
|
||||
forge: "gitea",
|
||||
changeRequestNumber: 1,
|
||||
headRef: "patch-1",
|
||||
localBranchName: preferred,
|
||||
baseRefName: "main",
|
||||
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/1/head" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.branchName).toBe(expected);
|
||||
expect(git("rev-parse", result.branchName)).toBe(prHead);
|
||||
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n");
|
||||
for (const branch of ["main", ...existing]) {
|
||||
expect(git("rev-parse", branch)).toBe(baseHead);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
|
||||
const created = await createLegacyWorktreeForTest({
|
||||
branchName: "orphan-admin-branch",
|
||||
|
||||
@@ -1778,13 +1778,30 @@ async function localBranchExists(cwd: string, branchName: string): Promise<boole
|
||||
}
|
||||
|
||||
async function resolveUniqueLocalBranchName(cwd: string, candidateBranch: string): Promise<string> {
|
||||
let newBranchName = candidateBranch;
|
||||
const { stdout } = await runGitCommand(
|
||||
["for-each-ref", "--format=%(refname:strip=2)", "refs/heads/"],
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
const existingBranches = new Set(stdout.trim().split("\n").filter(Boolean));
|
||||
const candidateSegments = candidateBranch.split("/");
|
||||
const segments = [...candidateSegments];
|
||||
let suffix = 1;
|
||||
while (await localBranchExists(cwd, newBranchName)) {
|
||||
newBranchName = `${candidateBranch}-${suffix}`;
|
||||
suffix++;
|
||||
while (true) {
|
||||
const branchName = segments.join("/");
|
||||
const parentConflict = segments.findIndex((_, index) =>
|
||||
existingBranches.has(segments.slice(0, index + 1).join("/")),
|
||||
);
|
||||
const childConflict = [...existingBranches].some((branch) =>
|
||||
branch.startsWith(`${branchName}/`),
|
||||
);
|
||||
if (parentConflict === -1 && !childConflict) {
|
||||
return branchName;
|
||||
}
|
||||
// Git refs form a namespace: a branch cannot also be another branch's
|
||||
// parent directory. Rename the conflicting segment, preserving the rest.
|
||||
const conflictIndex = parentConflict === -1 ? segments.length - 1 : parentConflict;
|
||||
segments[conflictIndex] = `${candidateSegments[conflictIndex]}-${suffix++}`;
|
||||
}
|
||||
return newBranchName;
|
||||
}
|
||||
|
||||
async function isBranchCheckedOut(cwd: string, branchName: string): Promise<boolean> {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
|
||||
const PAGES = ["/", "/docs/connectivity", "/changelog"];
|
||||
|
||||
for (const path of PAGES) {
|
||||
test(`${path} shows the sponsors and invites the next one`, async ({ page }) => {
|
||||
await page.goto(path);
|
||||
|
||||
const sponsors = page.getByRole("region", { name: "Sponsors", exact: true });
|
||||
await expect(sponsors.getByRole("link", { name: "Hoplite" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://hoplite.sh/?utm_source=paseo&utm_medium=website&utm_campaign=sponsor",
|
||||
);
|
||||
await expect(sponsors.getByText("Cloud coding agents", { exact: true })).toBeVisible();
|
||||
await expect(sponsors.getByRole("link", { name: "Become a sponsor" })).toHaveAttribute(
|
||||
"href",
|
||||
"/sponsor#spot",
|
||||
);
|
||||
});
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.4 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.4 KiB |
@@ -1,3 +1,4 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import type { Doc } from "~/docs";
|
||||
|
||||
const GITHUB_BASE_URL = "https://github.com/getpaseo/paseo/blob/main";
|
||||
@@ -6,9 +7,15 @@ export function DocsSourceFooter({ doc }: { doc: Doc }) {
|
||||
const sourceUrl = `${GITHUB_BASE_URL}/${doc.sourcePath}`;
|
||||
|
||||
return (
|
||||
<footer className="docs-source-footer">
|
||||
<a href={sourceUrl} target="_blank" rel="noreferrer">
|
||||
View this page on GitHub
|
||||
<footer className="mt-12 border-t border-white/10 py-8 text-sm">
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 font-medium text-white/60 underline-offset-4 transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
View on GitHub
|
||||
<ExternalLink aria-hidden="true" className="size-3.5" />
|
||||
</a>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -132,13 +132,13 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
<TurnkeySection />
|
||||
<AutomationSection />
|
||||
<ExtensibleSection />
|
||||
<FAQ />
|
||||
<PhilosophySection />
|
||||
<SponsorsSection placement="page" />
|
||||
<SponsorSection />
|
||||
<SponsorsSection />
|
||||
<PhilosophySection />
|
||||
<FAQ />
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<SiteFooter sponsors={false} />
|
||||
</div>
|
||||
</CursorFieldProvider>
|
||||
);
|
||||
@@ -468,46 +468,35 @@ function TurnkeySection() {
|
||||
title="Run it anywhere"
|
||||
description="Use Paseo locally, from another machine, or with a team"
|
||||
>
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02]">
|
||||
<div className="flex flex-col gap-6 border-b border-white/10 p-6 sm:flex-row sm:items-center sm:justify-between md:p-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.06] p-3 text-muted-foreground">
|
||||
<Monitor className="h-6 w-6" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="text-xl font-medium text-white/90">Desktop app</h3>
|
||||
<p className="max-w-lg text-sm leading-relaxed text-white/50">
|
||||
The one click experience, download the app and it just works, no server required
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<TurnkeyExtensionCard
|
||||
icon={Smartphone}
|
||||
title="Mobile"
|
||||
description="Full client and native, not a WebView"
|
||||
ctaHref="/download"
|
||||
ctaLabel="Download"
|
||||
/>
|
||||
<TurnkeyExtensionCard
|
||||
icon={Laptop}
|
||||
title="Remote machines"
|
||||
description="Run Paseo on a home lab, or a cloud machine"
|
||||
ctaHref="/docs#server--cli"
|
||||
ctaLabel="Docs"
|
||||
/>
|
||||
<TurnkeyExtensionCard
|
||||
icon={Users}
|
||||
title="Teams and triggers"
|
||||
description="Share access or start work from GitHub, Slack, and Discord"
|
||||
ctaHref="/hub"
|
||||
ctaLabel="Paseo Hub"
|
||||
showIntegrationIcons
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<ProductLead
|
||||
icon={Monitor}
|
||||
title="Desktop app"
|
||||
description="The one click experience, download the app and it just works, no server required"
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<TurnkeyExtensionCard
|
||||
icon={Smartphone}
|
||||
title="Mobile"
|
||||
description="Full client and native, not a WebView"
|
||||
ctaHref="/download"
|
||||
ctaLabel="Download"
|
||||
/>
|
||||
<TurnkeyExtensionCard
|
||||
icon={Laptop}
|
||||
title="Remote machines"
|
||||
description="Run Paseo on a home lab, or a cloud machine"
|
||||
ctaHref="/docs#server--cli"
|
||||
ctaLabel="Docs"
|
||||
/>
|
||||
<TurnkeyExtensionCard
|
||||
icon={Users}
|
||||
title="Teams and triggers"
|
||||
description="Share access or start work from GitHub, Slack, and Discord"
|
||||
ctaHref="/hub"
|
||||
ctaLabel="Paseo Hub"
|
||||
showIntegrationIcons
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
@@ -530,20 +519,8 @@ function TurnkeyExtensionCard({
|
||||
showIntegrationIcons?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-48 flex-col rounded-xl border border-white/10 bg-white/[0.025] p-5">
|
||||
<div className="mb-5 flex items-center gap-3 text-muted-foreground">
|
||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
{showIntegrationIcons ? (
|
||||
<>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
<SlackIcon className="h-4 w-4" />
|
||||
<DiscordIcon className="h-4 w-4" />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className="font-medium text-white/85">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-white/45">{description}</p>
|
||||
<div className="mt-auto pt-5">
|
||||
<FeatureCard icon={Icon} title={title} description={description}>
|
||||
<div className="flex items-center justify-between">
|
||||
<a
|
||||
href={ctaHref}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-2.5 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90"
|
||||
@@ -551,7 +528,70 @@ function TurnkeyExtensionCard({
|
||||
{ctaLabel}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
{showIntegrationIcons ? INTEGRATION_ICONS : null}
|
||||
</div>
|
||||
</FeatureCard>
|
||||
);
|
||||
}
|
||||
|
||||
const INTEGRATION_ICONS = (
|
||||
<span className="flex items-center gap-2 text-extra-muted-foreground">
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
<SlackIcon className="h-4 w-4" />
|
||||
<DiscordIcon className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
|
||||
/** The lead product of a section: an icon tile and title on the page background. */
|
||||
function ProductLead({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.06] p-3 text-muted-foreground">
|
||||
<Icon className="h-6 w-6" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="text-xl font-medium text-white/90">{title}</h3>
|
||||
{description ? (
|
||||
<p className="text-sm leading-relaxed text-white/50">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-3">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A small card: icon and title on one line, the blurb below, an optional action last. */
|
||||
function FeatureCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-white/10 bg-white/[0.025] p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-5 w-5 shrink-0 text-extra-muted-foreground" strokeWidth={1.5} />
|
||||
<h3 className="font-medium text-white/85">{title}</h3>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-white/45">{description}</p>
|
||||
{children ? <div className="mt-auto pt-5">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -797,41 +837,14 @@ function ExtensibleSection() {
|
||||
title="Extend it with plugins"
|
||||
description="Add new panels and functionality that work in desktop, web and mobile"
|
||||
>
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02]">
|
||||
<div className="flex flex-col gap-6 border-b border-white/10 p-6 sm:flex-row sm:items-center sm:justify-between md:p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.06] p-3 text-muted-foreground">
|
||||
<Puzzle className="h-6 w-6" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium text-white/90">Plugins</h3>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<a
|
||||
href={PLUGINS_BROWSE.href}
|
||||
{...(PLUGINS_BROWSE.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90"
|
||||
>
|
||||
Browse plugins
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href="/docs/plugins"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-white/10"
|
||||
>
|
||||
Plugin docs
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<ProductLead icon={Puzzle} title="Plugins" actions={PLUGIN_ACTIONS} />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{PLUGIN_CAPABILITIES.map((capability) => (
|
||||
<FeatureCard key={capability.title} {...capability} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{PLUGIN_CAPABILITIES.map((capability) => (
|
||||
<PluginCapabilityCard key={capability.title} {...capability} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-white/10 px-6 py-5 sm:flex-row sm:items-center sm:justify-between md:px-8">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-white/45">Paseo is Apache 2.0.</p>
|
||||
<a
|
||||
href="https://github.com/getpaseo/paseo"
|
||||
@@ -853,6 +866,25 @@ const PLUGINS_BROWSE = PLUGINS_LINKED
|
||||
? { href: "/plugins", external: false }
|
||||
: { href: "https://paseo.cafe", external: true };
|
||||
|
||||
const PLUGIN_ACTIONS = (
|
||||
<>
|
||||
<a
|
||||
href={PLUGINS_BROWSE.href}
|
||||
{...(PLUGINS_BROWSE.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90"
|
||||
>
|
||||
Browse plugins
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href="/docs/plugins"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-white/10"
|
||||
>
|
||||
Plugin docs
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
|
||||
const PLUGIN_CAPABILITIES: ReadonlyArray<{
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
@@ -866,36 +898,15 @@ const PLUGIN_CAPABILITIES: ReadonlyArray<{
|
||||
{
|
||||
icon: Workflow,
|
||||
title: "Agent lifecycle",
|
||||
description:
|
||||
"Change configuration, environment, and MCP servers. Answer permissions. Follow up when a turn ends",
|
||||
description: "Change config and MCP servers, answer permissions, and follow up",
|
||||
},
|
||||
{
|
||||
icon: Bot,
|
||||
title: "Providers",
|
||||
description: "Add a coding agent as a provider, used the same way as the built-in ones",
|
||||
description: "Add a coding agent as a provider, used like the built-in ones",
|
||||
},
|
||||
];
|
||||
|
||||
function PluginCapabilityCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-white/10 bg-white/[0.025] p-5">
|
||||
<div className="mb-5 text-muted-foreground">
|
||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="font-medium text-white/85">{title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-white/45">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: string }> = [
|
||||
{
|
||||
icon: Lock,
|
||||
@@ -920,7 +931,7 @@ const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description:
|
||||
{
|
||||
icon: Blocks,
|
||||
title: "Composable",
|
||||
description: "Run agents on your laptop or a server, and connect from any device.",
|
||||
description: "Run agents on a laptop or a server, and connect from any device.",
|
||||
},
|
||||
{
|
||||
icon: Puzzle,
|
||||
@@ -937,16 +948,7 @@ function PhilosophySection() {
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{PRINCIPLES.map((principle) => (
|
||||
<div
|
||||
key={principle.title}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.025] p-5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<principle.icon className="h-5 w-5 text-extra-muted-foreground" strokeWidth={1.5} />
|
||||
<h3 className="font-medium text-white/85">{principle.title}</h3>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-white/45">{principle.description}</p>
|
||||
</div>
|
||||
<FeatureCard key={principle.title} {...principle} />
|
||||
))}
|
||||
</div>
|
||||
</FeatureSection>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { PLUGINS_LINKED } from "~/plugins/launch";
|
||||
import { getAlternativePages } from "~/data/alternative-pages";
|
||||
import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads";
|
||||
import { SponsorsSection } from "~/components/sponsorship";
|
||||
|
||||
interface SiteFooterProps {
|
||||
width?: "default" | "prose" | "wide";
|
||||
/** Pages that show the sponsors in their content turn the footer band off. */
|
||||
sponsors?: boolean;
|
||||
}
|
||||
|
||||
const WIDTH_CLASSES: Record<NonNullable<SiteFooterProps["width"]>, string> = {
|
||||
@@ -12,11 +15,12 @@ const WIDTH_CLASSES: Record<NonNullable<SiteFooterProps["width"]>, string> = {
|
||||
wide: "max-w-7xl p-6 md:p-20 md:pt-0",
|
||||
};
|
||||
|
||||
export function SiteFooter({ width = "default" }: SiteFooterProps) {
|
||||
export function SiteFooter({ width = "default", sponsors = true }: SiteFooterProps) {
|
||||
const widthClasses = WIDTH_CLASSES[width];
|
||||
const alternatives = getAlternativePages();
|
||||
return (
|
||||
<footer className={`${widthClasses} mx-auto mt-12 md:mt-16`}>
|
||||
{sponsors ? <SponsorsSection placement="footer" /> : null}
|
||||
<div className="border-t border-white/10 pt-8 pb-4 grid grid-cols-2 sm:grid-cols-5 gap-8 text-sm">
|
||||
<div className="space-y-3">
|
||||
<p className="text-extra-muted-foreground">Product</p>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { ArrowRight, Check, ExternalLink } from "lucide-react";
|
||||
import { ArrowRight, Check, ExternalLink, Heart } from "lucide-react";
|
||||
import { BuyMeACoffeeIcon, GitHubIcon, OpenCollectiveIcon } from "~/components/brand-icons";
|
||||
import {
|
||||
BUY_ME_A_COFFEE_URL,
|
||||
GITHUB_SPONSORS_URL,
|
||||
HOMEPAGE_SPONSORS,
|
||||
HOMEPAGE_SPOT_PRICE,
|
||||
OPEN_COLLECTIVE_URL,
|
||||
SPONSORS,
|
||||
SPONSOR_CONTACT_EMAIL,
|
||||
SPONSOR_SPOT_CHECKOUT_URL,
|
||||
SPONSOR_SPOT_PRICE,
|
||||
openSpotCount,
|
||||
sponsorHref,
|
||||
} from "~/data/sponsors";
|
||||
|
||||
function SectionHeading({
|
||||
@@ -148,30 +149,11 @@ export function BackingOptions() {
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorLogoRow() {
|
||||
return (
|
||||
<ul className="grid grid-cols-2 gap-4">
|
||||
{HOMEPAGE_SPONSORS.map((sponsor) => (
|
||||
<li key={sponsor.href}>
|
||||
<a
|
||||
href={sponsor.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
className="flex h-32 items-center justify-center rounded-xl border border-white/10 bg-white/[0.02] px-6 transition-colors hover:border-white/20 hover:bg-white/[0.05]"
|
||||
>
|
||||
<img src={sponsor.logo} alt={sponsor.name} className="max-h-10 max-w-full opacity-80" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const SPOT_FEATURES: ReadonlyArray<string> = [
|
||||
"Your logo on the homepage of paseo.sh, around 70,000 visitors a month",
|
||||
"Your logo on the paseo.sh homepage and docs, around 70,000 visitors a month",
|
||||
"Your logo in the GitHub README, around 30,000 visitors a month",
|
||||
"An announcement in the Discord, around 2,000 members",
|
||||
"A mention in the changelog",
|
||||
"An announcement from me on X",
|
||||
];
|
||||
|
||||
export function SponsorSpotSection() {
|
||||
@@ -181,11 +163,11 @@ export function SponsorSpotSection() {
|
||||
<SectionHeading
|
||||
as="h2"
|
||||
title="Sponsor as a company"
|
||||
description="If your team relies on Paseo, a monthly sponsorship funds its development directly. As a thank you, your logo goes on the homepage and in the README."
|
||||
description="Support Paseo financially and get visibility for your company"
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="text-4xl font-medium tracking-tight">{HOMEPAGE_SPOT_PRICE}</span>
|
||||
<span className="text-4xl font-medium tracking-tight">{SPONSOR_SPOT_PRICE}</span>
|
||||
<span className="mb-1 text-sm text-white/45">a month</span>
|
||||
</div>
|
||||
<ul className="grid max-w-2xl gap-3 text-white/70">
|
||||
@@ -259,23 +241,78 @@ export function SponsorSection() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Homepage: the companies sponsoring Paseo. Renders nothing until there is one. */
|
||||
export function SponsorsSection() {
|
||||
if (HOMEPAGE_SPONSORS.length === 0) return null;
|
||||
const SPONSORS_PLACEMENTS = {
|
||||
page: {
|
||||
section: "space-y-10",
|
||||
heading: "gap-2.5 text-3xl font-medium tracking-tight",
|
||||
heart: "size-6",
|
||||
link: "",
|
||||
logo: "h-14",
|
||||
description: "mt-2 text-sm text-white/55",
|
||||
button: "h-12 px-5 text-sm",
|
||||
},
|
||||
footer: {
|
||||
section: "space-y-5 border-t border-white/10 py-8",
|
||||
heading: "gap-1.5 text-sm text-extra-muted-foreground",
|
||||
heart: "size-3",
|
||||
link: "opacity-85 transition-opacity hover:opacity-100",
|
||||
logo: "h-10",
|
||||
description: "mt-1.5 text-xs text-white/45",
|
||||
button: "h-9 px-3.5 text-sm",
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface SponsorsSectionProps {
|
||||
/** `page` is a homepage section; `footer` is the band above other pages' footers. */
|
||||
placement: keyof typeof SPONSORS_PLACEMENTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The companies sponsoring Paseo: one monochrome logo per sponsor and, while
|
||||
* spots are open, a quiet invitation. Every page shows it once.
|
||||
*/
|
||||
export function SponsorsSection({ placement }: SponsorsSectionProps) {
|
||||
const style = SPONSORS_PLACEMENTS[placement];
|
||||
const open = openSpotCount();
|
||||
return (
|
||||
<section>
|
||||
<SectionHeading as="h2" title="Sponsors" />
|
||||
<div className="space-y-4">
|
||||
<SponsorLogoRow />
|
||||
{openSpotCount() > 0 ? (
|
||||
<a
|
||||
href="/sponsor#spot"
|
||||
className="inline-block text-sm text-white/50 underline transition-colors hover:text-white/80"
|
||||
>
|
||||
Become a sponsor
|
||||
</a>
|
||||
<section aria-labelledby="sponsors-heading" className={style.section}>
|
||||
<h2 id="sponsors-heading" className={`flex items-center ${style.heading}`}>
|
||||
<Heart
|
||||
aria-hidden="true"
|
||||
className={`fill-rose-300/80 text-rose-300/80 ${style.heart}`}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
Sponsors
|
||||
</h2>
|
||||
<ul className="flex flex-wrap items-center gap-x-14 gap-y-8">
|
||||
{SPONSORS.map((sponsor) => (
|
||||
<li key={sponsor.url}>
|
||||
<a
|
||||
href={sponsorHref(sponsor, "website")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`block ${style.link}`}
|
||||
>
|
||||
<img
|
||||
src={sponsor.logo.onDark}
|
||||
alt={sponsor.name}
|
||||
className={`${style.logo} w-auto`}
|
||||
/>
|
||||
</a>
|
||||
<p className={style.description}>{sponsor.description}</p>
|
||||
</li>
|
||||
))}
|
||||
{open > 0 ? (
|
||||
<li>
|
||||
<a
|
||||
href="/sponsor#spot"
|
||||
className={`inline-flex items-center rounded-md border border-dashed border-white/15 text-white/45 transition-colors hover:border-white/30 hover:text-white/75 ${style.button}`}
|
||||
>
|
||||
Become a sponsor
|
||||
</a>
|
||||
</li>
|
||||
) : null}
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SPONSORS, sponsorHref } from "./sponsors";
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const WEBSITE_PUBLIC = fileURLToPath(new URL("../../public", import.meta.url));
|
||||
const READMES = ["README.md", "README.zh-CN.md", "README.ja.md", "README.ko.md"];
|
||||
const README_SPONSOR_CELLS = 3;
|
||||
const SPONSOR_PAGE_URL = "https://paseo.sh/sponsor";
|
||||
|
||||
describe("sponsorHref", () => {
|
||||
it("tags the link with the placement it is shown in", () => {
|
||||
const sponsor = {
|
||||
name: "Acme",
|
||||
url: "https://acme.dev",
|
||||
logo: { onDark: "", onLight: "" },
|
||||
description: "",
|
||||
};
|
||||
|
||||
expect(sponsorHref(sponsor, "website")).toBe(
|
||||
"https://acme.dev/?utm_source=paseo&utm_medium=website&utm_campaign=sponsor",
|
||||
);
|
||||
expect(sponsorHref(sponsor, "x")).toBe(
|
||||
"https://acme.dev/?utm_source=paseo&utm_medium=x&utm_campaign=sponsor",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function readmeSponsorCells(readme: string) {
|
||||
const text = readFileSync(`${REPO_ROOT}${readme}`, "utf8");
|
||||
const table = text.slice(text.indexOf("<!-- Sponsors:")).split("</table>")[0];
|
||||
return [...table.matchAll(/<td[^>]*>(.*?)<\/td>/g)].map(([, cell]) => {
|
||||
const logo = cell.match(
|
||||
/^<a href="([^"]+)"><picture><source media="\(prefers-color-scheme: dark\)" srcset="([^"]+)"><img src="([^"]+)"[^>]*><\/picture><\/a><br><sub>([^<]+)<\/sub>$/,
|
||||
);
|
||||
if (logo) {
|
||||
const [, href, onDark, onLight, description] = logo;
|
||||
return { href, onDark, onLight, description };
|
||||
}
|
||||
return { invite: cell.match(/^<a href="([^"]+)">/)?.[1] };
|
||||
});
|
||||
}
|
||||
|
||||
describe("SPONSORS", () => {
|
||||
it.each(SPONSORS)("ships both logo inks for $name", (sponsor) => {
|
||||
expect(existsSync(`${WEBSITE_PUBLIC}${sponsor.logo.onDark}`)).toBe(true);
|
||||
expect(existsSync(`${WEBSITE_PUBLIC}${sponsor.logo.onLight}`)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(READMES)("fill the sponsors table in %s, followed by invitations", (readme) => {
|
||||
const openCells = Math.max(0, README_SPONSOR_CELLS - SPONSORS.length);
|
||||
|
||||
expect(readmeSponsorCells(readme)).toEqual([
|
||||
...SPONSORS.map((sponsor) => ({
|
||||
href: sponsorHref(sponsor, "readme"),
|
||||
onDark: `packages/website/public${sponsor.logo.onDark}`,
|
||||
onLight: `packages/website/public${sponsor.logo.onLight}`,
|
||||
description: sponsor.description,
|
||||
})),
|
||||
...Array.from({ length: openCells }, () => ({ invite: SPONSOR_PAGE_URL })),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,59 @@
|
||||
/**
|
||||
* Homepage sponsor spots. This file is the inventory: the site shows one open
|
||||
* placeholder for every spot not listed in `HOMEPAGE_SPONSORS`.
|
||||
* Company sponsors. This file is the inventory: every page of the site shows
|
||||
* these logos above the footer, and the open spots invite the next sponsor.
|
||||
*
|
||||
* Approving a sponsor means three edits, or the site promises something it does
|
||||
* not deliver:
|
||||
*
|
||||
* 1. Add the entry below and drop its logo in `public/sponsors/`.
|
||||
* 2. Add the logo under the `## Sponsors` marker in `README.md`,
|
||||
* `README.zh-CN.md`, `README.ja.md` and `README.ko.md`, in this same order.
|
||||
* 1. Add the entry below and drop both logo inks in `public/sponsors/`.
|
||||
* 2. In the sponsors table after the intro of `README.md`, `README.zh-CN.md`,
|
||||
* `README.ja.md` and `README.ko.md`, replace a "become a sponsor" cell with
|
||||
* the logo and its description, in this same order, linked to `sponsorHref(sponsor, "readme")`
|
||||
* with both inks referenced from `packages/website/public`, so the README
|
||||
* renders from the repository and never waits on a site deploy.
|
||||
* `sponsors.test.ts` fails until they match.
|
||||
* 3. Remove all of it when the subscription ends.
|
||||
*
|
||||
* The Stripe payment link caps itself at `HOMEPAGE_SPOT_COUNT` completed
|
||||
* The Stripe payment link caps itself at `SPONSOR_SPOT_COUNT` completed
|
||||
* checkouts, and a checkout you decline and refund still counts against that
|
||||
* cap. When you refund one, raise the link's completed-session limit by one, or
|
||||
* the page will offer a spot the link refuses to sell.
|
||||
*/
|
||||
export interface HomepageSponsor {
|
||||
export interface Sponsor {
|
||||
name: string;
|
||||
href: string;
|
||||
/** Path under `public/`, e.g. `/sponsors/acme.svg`. Keep logos monochrome white. */
|
||||
logo: string;
|
||||
/** The sponsor's own URL, without tracking. Links go through `sponsorHref`. */
|
||||
url: string;
|
||||
/** Paths under `public/`, one per ground. Use the sponsor's official lockup. */
|
||||
logo: { onDark: string; onLight: string };
|
||||
/** What the product is, in a few words from the sponsor's own site. Shown under the logo. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const HOMEPAGE_SPONSORS: ReadonlyArray<HomepageSponsor> = [];
|
||||
export const SPONSORS: ReadonlyArray<Sponsor> = [
|
||||
{
|
||||
name: "Hoplite",
|
||||
url: "https://hoplite.sh",
|
||||
logo: {
|
||||
onDark: "/sponsors/hoplite-on-dark.svg",
|
||||
onLight: "/sponsors/hoplite-on-light.svg",
|
||||
},
|
||||
description: "Cloud coding agents",
|
||||
},
|
||||
];
|
||||
|
||||
export const HOMEPAGE_SPOT_COUNT = 4;
|
||||
export const HOMEPAGE_SPOT_PRICE = "$500";
|
||||
/** Where a sponsor link is shown. Becomes the link's `utm_medium`. */
|
||||
export type SponsorPlacement = "website" | "readme" | "discord" | "x";
|
||||
|
||||
export function sponsorHref(sponsor: Sponsor, placement: SponsorPlacement): string {
|
||||
const url = new URL(sponsor.url);
|
||||
url.searchParams.set("utm_source", "paseo");
|
||||
url.searchParams.set("utm_medium", placement);
|
||||
url.searchParams.set("utm_campaign", "sponsor");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export const SPONSOR_SPOT_COUNT = 4;
|
||||
export const SPONSOR_SPOT_PRICE = "$500";
|
||||
|
||||
/** Stripe Payment Link for the monthly spot subscription. */
|
||||
export const SPONSOR_SPOT_CHECKOUT_URL = "https://buy.stripe.com/8x24gBczR7LNaokcve2sM00";
|
||||
@@ -38,5 +66,5 @@ export const OPEN_COLLECTIVE_URL =
|
||||
export const BUY_ME_A_COFFEE_URL = "https://buymeacoffee.com/paseo";
|
||||
|
||||
export function openSpotCount(): number {
|
||||
return Math.max(0, HOMEPAGE_SPOT_COUNT - HOMEPAGE_SPONSORS.length);
|
||||
return Math.max(0, SPONSOR_SPOT_COUNT - SPONSORS.length);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { DocsBreadcrumbs } from "~/components/docs-breadcrumbs";
|
||||
import { DocsNav } from "~/components/docs-nav";
|
||||
import { DocsOutline } from "~/components/docs-outline";
|
||||
import { SponsorsSection } from "~/components/sponsorship";
|
||||
import { buildDocsNavTree, getDoc, getDocs } from "~/docs";
|
||||
import "~/styles.css";
|
||||
|
||||
@@ -63,6 +64,7 @@ function DocsLayout() {
|
||||
<div className="max-w-prose mx-auto">
|
||||
{doc && <DocsBreadcrumbs doc={doc} tree={tree} />}
|
||||
<Outlet />
|
||||
<SponsorsSection placement="footer" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -561,23 +561,6 @@
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.docs-source-footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.docs-source-footer a {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.docs-source-footer a:hover {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* Blog post prose styles — adapted from moboudra.com */
|
||||
.blog-prose {
|
||||
color: rgba(250, 250, 250, 0.82);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user