mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-16 06:12:40 +00:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8388f16186 | ||
|
|
5c2a1552df | ||
|
|
e0de0fdf6f | ||
|
|
8d424e039e | ||
|
|
d9b874f9d0 | ||
|
|
f56c013928 | ||
|
|
208e323035 | ||
|
|
ec7446add4 | ||
|
|
b30302b630 | ||
|
|
7aa3fac069 | ||
|
|
fd1e7a34ce | ||
|
|
ce0f21e2ff | ||
|
|
d172d060a5 | ||
|
|
2d86b94b2f | ||
|
|
d6d4084a06 | ||
|
|
16bb179e67 | ||
|
|
e08389065c | ||
|
|
80badb3b8f | ||
|
|
71f575e16a | ||
|
|
0708b8295c | ||
|
|
4d5c3c13d4 | ||
|
|
d3efddb0b5 | ||
|
|
0ed57187a9 | ||
|
|
a96c892302 | ||
|
|
74282dac12 | ||
|
|
12066431ad | ||
|
|
4d8bed9ddd | ||
|
|
b2ca7dcf03 | ||
|
|
8b1ba3ca58 | ||
|
|
e5124b039a | ||
|
|
5c108f345c | ||
|
|
9ab99a6e1e | ||
|
|
8ff2424da1 | ||
|
|
6db47b3c37 | ||
|
|
73221ce5bd | ||
|
|
6ad124758c | ||
|
|
f3ddf99fe3 | ||
|
|
404d08d24b | ||
|
|
6d43a8c5b0 | ||
|
|
e1a5664d9b | ||
|
|
b58af43a06 | ||
|
|
1bf3a57a1c | ||
|
|
56448842f0 | ||
|
|
14e6384516 | ||
|
|
cafa04d3fb | ||
|
|
938c4e2632 | ||
|
|
d57096a456 | ||
|
|
ada7ba43f0 | ||
|
|
fc8f085b9e | ||
|
|
019e5279e9 | ||
|
|
4a3ff87eec | ||
|
|
a4f1df3aed |
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.4 - 2026-10-15
|
||||
|
||||
[Hoplite](https://hoplite.sh) joins Paseo as our first sponsor through the company sponsorship program. Hoplite builds cloud coding agents that turn tasks from GitHub, Linear, Slack and more into pull requests. Thank you to the Hoplite team for supporting Paseo.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Hoplite showing an error in Settings → Providers instead of discovering models in workspaces linked to a Hoplite project
|
||||
|
||||
## 0.11.3 - 2026-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added Hoplite as an opt-in provider in the ACP provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953) by [@odinglyn0](https://github.com/odinglyn0))
|
||||
- Added search to the Add provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953), [#7030](https://github.com/getpaseo/paseo/pull/7030))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed Add provider failures to show as a toast ([#7030](https://github.com/getpaseo/paseo/pull/7030))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Remove being offered for plugin providers such as Antigravity and Muse Code ([#6568](https://github.com/getpaseo/paseo/pull/6568) by [@Ctrl-Creeper](https://github.com/Ctrl-Creeper))
|
||||
|
||||
## 0.11.2 - 2026-10-09
|
||||
|
||||
### Fixed
|
||||
|
||||
+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="56"></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.
|
||||
|
||||
|
||||
@@ -58,12 +58,17 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
through that hook; the web viewport once skipped it and history hosts of a live tool group went
|
||||
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
|
||||
silently stops.
|
||||
- **Streaming cost must not grow with revealed history.** Reading older messages must not
|
||||
make the next reply progressively more expensive to display. Reuse unchanged history work
|
||||
while text arrives, and release obsolete window caches as the reader reveals more history;
|
||||
window caches must stay bounded while their source timeline remains loaded.
|
||||
|
||||
## Measuring
|
||||
|
||||
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
|
||||
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
|
||||
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
|
||||
- **Per-delta cost on a long timeline:** `npx vitest bench --project unit src/agent-stream/stream-tick.bench.ts` from `packages/app` replays 200 deltas over a 2,000-item timeline through the pure model and layout pipeline, with the default mounted window and with history fully revealed. It measures the JS work per delta, not paint.
|
||||
|
||||
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
|
||||
|
||||
|
||||
@@ -484,7 +484,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Most catalog entries create the same `extends: "acp"` provider config shown below. Hoplite activates its [bundled provider](plugins.md#built-in-plugins) instead.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
|
||||
+4
-1
@@ -589,7 +589,10 @@ host, kinds, and ids. Opening a cached workspace uses exact workspace and projec
|
||||
directory scan. One invalid row is deleted and returned as a miss without affecting other rows.
|
||||
An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a
|
||||
later launch cannot accept a checkpoint for a partial baseline. Directory changes and their
|
||||
checkpoint are also applied in one transaction.
|
||||
checkpoint are also applied in one transaction. Reads wait for accepted changes to reach storage,
|
||||
then retry only when a requested row changes during the read. Unrelated agent streams do not
|
||||
invalidate the read. Directory baseline replacements and host removal or renaming still invalidate
|
||||
the affected reads.
|
||||
|
||||
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget
|
||||
bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or
|
||||
|
||||
+22
-10
@@ -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 | Holds one tab; closes or becomes a tab |
|
||||
| Sheets | Compact look-ups | One tab each, on the app-wide sheet stack |
|
||||
|
||||
## Panel host contract
|
||||
|
||||
@@ -23,18 +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. The sheet holds one ordinary
|
||||
`WorkspaceTab`, which 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 sheet is modal and covers the whole compact column, header included. It renders in the
|
||||
workspace tree rather than through `IsolatedBottomSheetModal`: its panel needs 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)).
|
||||
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. 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
|
||||
|
||||
+59
-8
@@ -198,6 +198,47 @@ viewport keeps the last keyboard reservation until the next opening replaces
|
||||
it, so a capped draft keeps one height through every keyboard transition
|
||||
instead of expanding on close and shrinking again on open.
|
||||
|
||||
Fullscreen is a layout mode of the dock, not a second editor. `ComposerDock
|
||||
fullscreen` fills the stationary viewport with the composer slot over the
|
||||
still-mounted content, paints its own surface under the safe areas, and turns
|
||||
the surface translation off. The composer's single tree keeps the editing
|
||||
surface at the same position in both modes, so the mounted editor, its native
|
||||
buffer, selection, and focus survive the toggle and Send's clear always reaches
|
||||
the live input. The one remount left is a viewport crossing the compact
|
||||
breakpoint: the app shell swaps its gesture wrapper and the workspace screen
|
||||
mounts tabs in different trees, so every screen below remounts. The draft owner
|
||||
restores the text and the editing session restores the caret and focus intent.
|
||||
New workspace keys its sessions by draft in the app-level store for the same
|
||||
reason. The centered form honors fullscreen by style alone: no centering, no
|
||||
translation, setup fields hidden, slot filling the stationary viewport. The
|
||||
global exit action (Escape, hardware back) is registered only for the active
|
||||
composer: a retained tab keeps its fullscreen editor mounted while hidden, the
|
||||
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. 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
|
||||
also what lets a zoomed mobile page pan under it. Inside the slot, `ComposerFullscreenBody` reserves the keyboard
|
||||
height as a bottom margin at move start and end (boundary-time, like the
|
||||
capacity reservation) while `ComposerFullscreenFooter` rides the keyboard per
|
||||
frame with `KeyboardTranslateView`, so Send lands on the keyboard without a gap
|
||||
and without animating the editor's height. The screen that owns the chrome
|
||||
unmounts it: the workspace screen reads `useComposerTabFullscreen` for the
|
||||
active tab and New workspace reads the mounted composer's session. Hosts that
|
||||
render the dock pass the same `ComposerEditingSession` to the `Composer` so the
|
||||
dock, chrome, and editor agree on one fullscreen state. Escape (web overlay
|
||||
registration, focus untouched) and the Android back button leave fullscreen.
|
||||
|
||||
Keep setup fields out of the composer's shrink chain. New workspace supplies its
|
||||
setup fields as the first dock child so they move up under the header as the draft
|
||||
grows. Putting the fields and composer in one bounded, shrinkable column made
|
||||
@@ -302,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,
|
||||
@@ -316,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** — The compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `sheetByWorkspace` 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
|
||||
|
||||
@@ -107,6 +107,12 @@ absent from the installed plugin list and source configuration; their client bun
|
||||
appear in the plugin catalog. Editing one in development requires a daemon restart.
|
||||
Directory, Git, and npm installs cannot use a built-in ID.
|
||||
|
||||
A provider registration with `requiresConfiguration: true` participates only while its ID exists
|
||||
under `agents.providers`. Hoplite uses this for catalog opt-in. Add writes `{ "enabled": true }`
|
||||
without `extends`, so it selects the bundled adapter. Remove deletes the config entry; setting
|
||||
`enabled: false` keeps the entry. The bundled JavaScript still loads at startup, but an absent
|
||||
provider does not enter executable resolution or catalog discovery.
|
||||
|
||||
Provider plugins use separate installation and provider IDs. `muse-provider` registers the
|
||||
selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports
|
||||
availability and a diagnostic after the daemon resolves the executable. Configure command,
|
||||
|
||||
@@ -86,6 +86,12 @@ buildNpmPackage {
|
||||
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
|
||||
# to phone home during the build.
|
||||
CI = "1";
|
||||
# Metro's worker runs out of heap transforming the generated
|
||||
# ws-outbound.aot.js validator (about 12 MB). Node's default heap limit is
|
||||
# 2 GB on machines with less than 15 GB of memory, such as GitHub's 7 GB
|
||||
# macOS runners, and 4 GB above that. 4096 MB is that 4 GB ceiling, so this
|
||||
# only raises the limit on smaller builders, like desktop-release.yml does.
|
||||
NODE_OPTIONS = "--max-old-space-size=4096";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
closeModelPicker,
|
||||
openModelPicker,
|
||||
seedAgentProfiles,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
findAcpCatalogProvider,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
seedSavedSettingsHosts,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
const ACP_PROVIDER = {
|
||||
@@ -16,8 +27,13 @@ const ACP_PROVIDER = {
|
||||
};
|
||||
|
||||
interface ProviderCatalogDaemonClient {
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { hopliteProvider?: boolean; plugins?: boolean };
|
||||
} | null;
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getDaemonConfig(): Promise<{ config: { providers?: Record<string, unknown> } }>;
|
||||
getProvidersSnapshot(): Promise<{ entries: Array<{ provider: string }> }>;
|
||||
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -41,3 +57,124 @@ test.describe("ACP provider catalog", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "hoplite-catalog-e2e",
|
||||
});
|
||||
try {
|
||||
expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(true);
|
||||
await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
|
||||
expect(
|
||||
(await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
|
||||
).toBe(false);
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: value }));
|
||||
}, theme);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await openAddProviderArea(page);
|
||||
await findAcpCatalogProvider(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) });
|
||||
await installAcpCatalogProvider(page, "Hoplite");
|
||||
await expectProviderInstalledInSettings(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-installed-${theme}.png`) });
|
||||
expect((await client.getDaemonConfig()).config.providers?.hoplite).toEqual({ enabled: true });
|
||||
await writeFile(
|
||||
testInfo.outputPath(`hoplite-added-${theme}.json`),
|
||||
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
|
||||
);
|
||||
await removeHoplite(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined();
|
||||
expect(
|
||||
(await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
|
||||
).toBe(false);
|
||||
await writeFile(
|
||||
testInfo.outputPath(`hoplite-removed-${theme}.json`),
|
||||
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
|
||||
);
|
||||
await openAddProviderArea(page);
|
||||
await findAcpCatalogProvider(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) });
|
||||
await installAcpCatalogProvider(page, "Hoplite");
|
||||
await expectProviderInstalledInSettings(page, "Hoplite");
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ removeProviders: ["hoplite"] }).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function removeHoplite(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.getByTestId("provider-actions-hoplite").click();
|
||||
page.once("dialog", (dialog) => {
|
||||
void dialog.accept();
|
||||
});
|
||||
await page.getByTestId("provider-remove-hoplite").click();
|
||||
}
|
||||
|
||||
test("unsupported host asks for an update without writing Hoplite config", async ({
|
||||
page,
|
||||
outdatedDaemon,
|
||||
}, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "hoplite-unsupported",
|
||||
port: Number(new URL(`http://${outdatedDaemon.endpoint}`).port),
|
||||
});
|
||||
try {
|
||||
expect(client.getLastServerInfoMessage()?.features?.plugins).toBe(true);
|
||||
expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(false);
|
||||
const before = await client.getDaemonConfig();
|
||||
await seedSavedSettingsHosts(page, [outdatedDaemon]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, outdatedDaemon.serverId);
|
||||
await openSettingsHostSection(page, outdatedDaemon.serverId, "providers");
|
||||
await openAddProviderArea(page);
|
||||
await page.getByRole("button", { name: "Add Hoplite", exact: true }).click();
|
||||
const errorToast = page.getByTestId("app-toast-message");
|
||||
await expect(errorToast).toHaveText("Update the host to add Hoplite.");
|
||||
await expect(errorToast).toBeInViewport();
|
||||
expect((await client.getDaemonConfig()).config).toEqual(before.config);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("hoplite-update-host.png") });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("model selector excludes Hoplite before opt-in", async ({ page }, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "hoplite-picker",
|
||||
});
|
||||
const profiles = await seedAgentProfiles([
|
||||
{ id: "hoplite-picker-anchor", name: "Picker anchor", provider: "mock" },
|
||||
]);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "hoplite-picker-" });
|
||||
try {
|
||||
await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
await expect(page.getByTestId("model-provider-mock")).toBeVisible();
|
||||
await expect(page.getByTestId("model-provider-hoplite")).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("hoplite-model-selector-absent.png") });
|
||||
await closeModelPicker(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await profiles.restore();
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import {
|
||||
@@ -12,12 +12,15 @@ import {
|
||||
chooseAddProjectMethod,
|
||||
expectAddProjectPage,
|
||||
expectNewWorkspaceForAddedProject,
|
||||
expectAddProjectResultVisible,
|
||||
navigateAddProjectResults,
|
||||
openAddProjectFlow,
|
||||
openAddProjectHostSelection,
|
||||
} from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
addConnectedHostAndReload,
|
||||
addConnectedHostsAndReload,
|
||||
addOfflineHostAndReload,
|
||||
waitForConnectedHost,
|
||||
} from "../support/helpers/hosts";
|
||||
@@ -214,6 +217,60 @@ test.describe("Add Project command-center flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("filtered hosts keep the keyboard-selected row visible", async ({ page }) => {
|
||||
const hosts: IsolatedHostDaemon[] = [];
|
||||
try {
|
||||
for (let index = 1; index <= 3; index += 1) {
|
||||
hosts.push(await startIsolatedHostDaemon(`scroll-host-${index}`));
|
||||
}
|
||||
await page.setViewportSize({ width: 1280, height: 360 });
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostsAndReload(
|
||||
page,
|
||||
hosts.map((host, index) => ({
|
||||
serverId: host.serverId,
|
||||
port: host.port,
|
||||
label: index < 2 ? `Fleet 0${index + 1}` : "Other host",
|
||||
})),
|
||||
{ primaryLabel: "Fleet 00" },
|
||||
);
|
||||
for (const host of hosts) {
|
||||
await waitForConnectedHost(page, {
|
||||
serverId: host.serverId,
|
||||
endpoint: `localhost:${host.port}`,
|
||||
});
|
||||
}
|
||||
await openAddProjectHostSelection(page);
|
||||
await expect(
|
||||
addProjectFlow(page).getByRole("button").filter({ hasText: "Other host" }),
|
||||
).toBeVisible();
|
||||
|
||||
await addProjectFlowInput(page).fill("Fleet");
|
||||
await expect(addProjectFlowHost(page, hosts[2]!.serverId)).toHaveCount(0);
|
||||
await navigateAddProjectResults(page, "ArrowDown", 2);
|
||||
await expectAddProjectResultVisible(page, "Fleet 02");
|
||||
|
||||
// Filtering out the first host moves retained rows to new positions.
|
||||
await addProjectFlowInput(page).fill("scroll-host");
|
||||
await expect(addProjectFlowHost(page, getServerId())).toHaveCount(0);
|
||||
await expectAddProjectResultVisible(page, "Fleet 01");
|
||||
await navigateAddProjectResults(page, "ArrowDown", 2);
|
||||
await expectAddProjectResultVisible(page, "Other host");
|
||||
await navigateAddProjectResults(page, "ArrowDown", 1);
|
||||
await expectAddProjectResultVisible(page, "Fleet 01");
|
||||
|
||||
await addProjectFlowInput(page).fill("Fleet");
|
||||
await expectAddProjectResultVisible(page, "Fleet 00");
|
||||
await navigateAddProjectResults(page, "ArrowDown", 2);
|
||||
await expectAddProjectResultVisible(page, "Fleet 02");
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "method");
|
||||
await expect(addProjectFlow(page)).toContainText("Fleet 02");
|
||||
} finally {
|
||||
await Promise.all(hosts.map((host) => host.close()));
|
||||
}
|
||||
});
|
||||
|
||||
test("keyboard directory search adds the selected Project", async ({
|
||||
page,
|
||||
projectPickerFixture,
|
||||
@@ -223,6 +280,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "directory-search");
|
||||
await expect(addProjectFlowInput(page)).toBeFocused();
|
||||
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
|
||||
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
|
||||
timeout: 30_000,
|
||||
@@ -240,6 +298,59 @@ test.describe("Add Project command-center flow", () => {
|
||||
await expectProjectHasNoWorkspaces(projectId);
|
||||
});
|
||||
|
||||
test("keyboard navigation keeps the selected row inside the visible results", async ({
|
||||
page,
|
||||
}) => {
|
||||
const parentDirectory = await mkdtemp(path.join(homedir(), "paseo-e2e-scroll-"));
|
||||
const rowPath = (index: number) =>
|
||||
path.join(parentDirectory, `scroll-probe-${String(index).padStart(2, "0")}`);
|
||||
let projectId: string | null = null;
|
||||
try {
|
||||
await Promise.all(Array.from({ length: 16 }, (_, index) => mkdir(rowPath(index))));
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
await chooseAddProjectMethod(page, "directory-search");
|
||||
await addProjectFlowInput(page).fill(path.basename(parentDirectory));
|
||||
const results = page.getByTestId("add-project-flow-results");
|
||||
await expect(results.getByRole("button", { name: /scroll-probe-/ })).toHaveCount(16, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await navigateAddProjectResults(page, "ArrowDown", 12);
|
||||
await expectAddProjectResultVisible(page, rowPath(11));
|
||||
await navigateAddProjectResults(page, "ArrowUp", 12);
|
||||
await expectAddProjectResultVisible(page, parentDirectory);
|
||||
await navigateAddProjectResults(page, "ArrowUp", 1);
|
||||
await expectAddProjectResultVisible(page, rowPath(15));
|
||||
await navigateAddProjectResults(page, "ArrowDown", 1);
|
||||
await expectAddProjectResultVisible(page, parentDirectory);
|
||||
|
||||
await navigateAddProjectResults(page, "ArrowDown", 12);
|
||||
await expectAddProjectResultVisible(page, rowPath(11));
|
||||
await addProjectFlowInput(page).fill(`${path.basename(parentDirectory)}probe`);
|
||||
await expect(results.getByRole("button")).toHaveCount(16);
|
||||
await expectAddProjectResultVisible(page, rowPath(0));
|
||||
await navigateAddProjectResults(page, "ArrowDown", 12);
|
||||
await expectAddProjectResultVisible(page, rowPath(12));
|
||||
await addProjectFlowInput(page).fill(path.basename(parentDirectory));
|
||||
await expect(results.getByRole("button")).toHaveCount(17);
|
||||
await expectAddProjectResultVisible(page, parentDirectory);
|
||||
await navigateAddProjectResults(page, "ArrowDown", 12);
|
||||
await expectAddProjectResultVisible(page, rowPath(11));
|
||||
await page.keyboard.press("Enter");
|
||||
projectId = await expectOpenedProject(page, "scroll-probe-11");
|
||||
await expectNewWorkspaceForAddedProject(page, {
|
||||
serverId: getServerId(),
|
||||
projectId,
|
||||
projectName: "scroll-probe-11",
|
||||
projectPath: rowPath(11),
|
||||
});
|
||||
} finally {
|
||||
if (projectId) await removeCreatedProject(rowPath(11), projectId);
|
||||
await rm(parentDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a complete repository URL remains selectable without a GitHub search result", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
||||
import { startRunningMockAgent } from "../support/helpers/composer";
|
||||
import { startRunningMockAgent, submitMessage } from "../support/helpers/composer";
|
||||
import {
|
||||
seedLongMockAgentTimeline,
|
||||
openAgentTimeline,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
expectTimelinePromptVisible,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import {
|
||||
sampleStreamFrames,
|
||||
summarizeStreamSmoothness,
|
||||
@@ -102,6 +109,40 @@ agentStreamPerfDescribe("Agent stream smoothness", () => {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps streaming work bounded after revealing older history", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.addInitScript(() => {
|
||||
Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true);
|
||||
});
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80, liveTurns: "thirty-minute-stream" });
|
||||
try {
|
||||
await openAgentTimeline(page, agent);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
|
||||
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await submitMessage(page, "Stream a long answer after reading older messages.");
|
||||
await awaitAssistantMessage(page);
|
||||
|
||||
for (let sample = 0; sample < 3; sample += 1) {
|
||||
await page.evaluate(() => globalThis.__PASEO_RESET_RENDER_PROFILE__?.());
|
||||
const frames = await sampleStreamFrames(page, { sampleWindowMs: SAMPLE_WINDOW_MS });
|
||||
const smoothness = summarizeStreamSmoothness(frames, SAMPLE_WINDOW_MS);
|
||||
const react = summarizeReactCommits(await readStreamReactCommits(page));
|
||||
await testInfo.attach(`revealed-history-stream-${sample}`, {
|
||||
body: JSON.stringify({ smoothness, react }, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[perf] Revealed history stream: ${JSON.stringify({ smoothness, react })}`);
|
||||
expect(smoothness.growthFrames).toBeGreaterThan(20);
|
||||
expect(react.count).toBeGreaterThan(20);
|
||||
expect(react.actualDurationP95Ms).toBeLessThan(REACT_COMMIT_P95_BUDGET_MS);
|
||||
}
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function readStreamReactCommits(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -133,6 +133,58 @@ test("scroll detector accounts for delayed wheel input and growth below the new
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector follows the entered image through resize and virtual offset correction", () => {
|
||||
for (const height of [225, 1632]) {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2208,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 516,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -704, height: 560 },
|
||||
{ id: "caption", top: -144, height: 100 },
|
||||
{ id: "old-reader", top: -44, height: 127 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 3,
|
||||
unmounted: 0,
|
||||
};
|
||||
const resize = height - 560;
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1060,
|
||||
// The virtualizer corrects 110px of estimates while the wheel moves 160px.
|
||||
scrollTop: 1938,
|
||||
scrollHeight: 5000 + resize - 110,
|
||||
wheelTotal: 160,
|
||||
lastWheelAt: 1020,
|
||||
imageLoads: 1,
|
||||
rows: [
|
||||
{ id: "image", top: -544, height },
|
||||
{ id: "caption", top: 16 + resize, height: 100 },
|
||||
{ id: "old-reader", top: 116 + resize, height: 127 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
// Resizing must not excuse additional viewport movement.
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
for (const cadence of scrollCadences) {
|
||||
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
|
||||
page,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
async function openAppearance(page: Page) {
|
||||
await page.goto("/settings");
|
||||
await openSettingsSection(page, "appearance");
|
||||
}
|
||||
|
||||
async function setFontFamily(page: Page, name: string, family: string) {
|
||||
const input = page.getByRole("textbox", { name });
|
||||
await input.fill(family);
|
||||
await input.press("Tab");
|
||||
}
|
||||
|
||||
for (const name of ["Code font family", "Interface font family"]) {
|
||||
test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => {
|
||||
await openAppearance(page);
|
||||
await setFontFamily(page, name, "'Fira Code', monospace");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
|
||||
await setFontFamily(page, name, "'foo");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath("rejected-font.png") });
|
||||
await page.reload();
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await setFontFamily(page, name, "");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("");
|
||||
});
|
||||
}
|
||||
|
||||
test("opens settings with defaults when a malformed font was already saved", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ monoFontFamily: "'foo", uiFontFamily: '"foo' }),
|
||||
);
|
||||
});
|
||||
await openAppearance(page);
|
||||
await expect(page.getByRole("textbox", { name: "Code font family" })).toHaveValue("");
|
||||
await expect(page.getByRole("textbox", { name: "Interface font family" })).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -9,7 +9,12 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
@@ -96,16 +101,6 @@ interface PopoverFrameRecorderWindow extends Window {
|
||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||
}
|
||||
|
||||
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
|
||||
return page.evaluate(
|
||||
([pointX, pointY]) => {
|
||||
const element = document.elementFromPoint(pointX, pointY);
|
||||
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
}
|
||||
|
||||
async function installListCommandsStub(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
@@ -701,19 +696,15 @@ test.describe("Composer autocomplete", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
// The sidebar covers the composer, so the composer gives up its popover while the
|
||||
// sidebar is open and brings it back once the sidebar closes.
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
const topTestId = await getTopTestIdAtPoint(
|
||||
page,
|
||||
popoverBox!.x + popoverBox!.width / 2,
|
||||
popoverBox!.y + popoverBox!.height / 2,
|
||||
);
|
||||
|
||||
expect(topTestId).not.toBe("composer-autocomplete-popover");
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,124 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
blurComposerInput,
|
||||
composerLocator,
|
||||
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";
|
||||
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
|
||||
import {
|
||||
expectTimelinePromptPositionPreserved,
|
||||
rememberTimelinePromptPosition,
|
||||
seedLongMockAgentTimeline,
|
||||
waitForTimelineGeometryToSettle,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
|
||||
test("clicking chat text or background focuses the feed for keyboard paging", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 8 });
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
const composer = composerLocator(page);
|
||||
const feed = page.getByTestId("agent-chat-scroll").filter({ visible: true });
|
||||
await composer.fill("Keep this draft");
|
||||
await feed.getByText(agent.newestPrompt, { exact: true }).click();
|
||||
await expect(feed).toBeFocused();
|
||||
|
||||
const beforePaging = await readScrollMetrics(page);
|
||||
expect(beforePaging.offsetY).toBeGreaterThan(beforePaging.viewportHeight);
|
||||
await page.keyboard.press("PageUp");
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).offsetY)
|
||||
.toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") });
|
||||
await page.keyboard.press("PageDown");
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
|
||||
.toBeLessThanOrEqual(2);
|
||||
|
||||
await composer.click();
|
||||
await expectComposerFocused(page);
|
||||
await feed.click({ position: { x: 2, y: 40 } });
|
||||
await expect(feed).toBeFocused();
|
||||
await page.keyboard.press("PageUp");
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
|
||||
.toBeGreaterThan(beforePaging.viewportHeight / 2);
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
const readingPrompt = agent.prompts[4];
|
||||
await expect(feed.getByText(readingPrompt, { exact: true })).toBeInViewport();
|
||||
const readingPosition = await rememberTimelinePromptPosition(page, readingPrompt);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "emit 2 coalesced agent stream updates");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
// The daemon finishing does not mean its response has reached the browser.
|
||||
await expect(feed.getByText(/stress-update-1/)).toHaveCount(1);
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
await expectTimelinePromptPositionPreserved(page, readingPosition);
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("focusing the feed preserves assistant text selection and code copy controls", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sentence = "Select this assistant sentence.";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-focus-selection-",
|
||||
title: "Chat focus selection",
|
||||
initialPrompt: "Render text and code.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: `${sentence}\n\n\`\`\`bash\necho example\n\`\`\``,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await composerLocator(page).fill("Keep this draft");
|
||||
|
||||
const feed = page.getByTestId("agent-chat-scroll").filter({ visible: true });
|
||||
await feed.getByText(sentence, { exact: true }).click({ clickCount: 3 });
|
||||
await expect(feed).toBeFocused();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString().trim())).toBe(sentence);
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect((await page.evaluate(() => navigator.clipboard.readText())).trim()).toBe(sentence);
|
||||
|
||||
const code = feed.locator('[data-paseo-markdown-language="bash"]');
|
||||
await code.hover();
|
||||
await code.locator("[data-paseo-markdown-ignore]").click();
|
||||
await expect(feed).not.toBeFocused();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("echo example");
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
@@ -76,3 +187,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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
queueComposerWithButton,
|
||||
configureComposerQueueSend,
|
||||
activateRetainedComposerTab,
|
||||
switchComposerTabWithKeyboard,
|
||||
expectWorkspaceHeaderHidden,
|
||||
expectWorkspaceHeaderVisible,
|
||||
recordVisibleComposerGeometry,
|
||||
expectNoCollapsedVisibleComposer,
|
||||
expectAttachmentPill,
|
||||
@@ -27,8 +30,15 @@ import {
|
||||
startRunningMockAgent,
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
expectComposerFullscreenFitsVisibleViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function composerHeight(page: Page): Promise<number> {
|
||||
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
|
||||
@@ -156,6 +166,26 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
|
||||
}
|
||||
});
|
||||
|
||||
/** 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 });
|
||||
// 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. */
|
||||
async function expectFullscreenErrorAboveSend(page: Page): Promise<void> {
|
||||
const frame = page.getByTestId("composer-fullscreen");
|
||||
const alertBox = (await frame.getByRole("alert").boundingBox())!;
|
||||
const sendBox = (await page
|
||||
.getByRole("button", { name: "Send message", exact: true })
|
||||
.boundingBox())!;
|
||||
const editorBox = (await composerLocator(page).boundingBox())!;
|
||||
expect(alertBox.y).toBeGreaterThanOrEqual(editorBox.y + editorBox.height);
|
||||
expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(sendBox.y);
|
||||
}
|
||||
|
||||
async function writeCompactDraft(page: Page, text: string): Promise<void> {
|
||||
await composerLocator(page).fill(text);
|
||||
await expect(composerLocator(page)).toHaveValue(text);
|
||||
@@ -177,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({
|
||||
@@ -188,47 +218,109 @@ test("compact fullscreen editing preserves the live draft and selection and stay
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await attachComposerTestImage(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await writeCompactDraft(page, "First line\nSecond line");
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
|
||||
await writeCompactDraft(
|
||||
page,
|
||||
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
|
||||
4,
|
||||
),
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
|
||||
await selectComposerDraftRange(page, 4, 17);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerDraftSelection(page, 4, 17);
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
|
||||
await selectComposerDraftRange(page, 3, 12);
|
||||
await exitComposerFullscreen(page);
|
||||
await expect(composerLocator(page)).toHaveValue(
|
||||
"Edited fullscreen draft\nSecond line\nThird line",
|
||||
);
|
||||
await expectComposerDraftSelection(page, 3, 12);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await enterComposerFullscreen(page);
|
||||
await writeCompactDraft(page, "Short draft");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await selectComposerDraftRange(page, 0, 5);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerDraftSelection(page, 0, 5);
|
||||
await expect(composerLocator(page)).toHaveValue("Short draft");
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await test.step("the affordance appears after two rendered lines, including soft wraps", async () => {
|
||||
await attachComposerTestImage(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await writeCompactDraft(page, "First line\nSecond line");
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
|
||||
await writeCompactDraft(
|
||||
page,
|
||||
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
|
||||
4,
|
||||
),
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
|
||||
});
|
||||
await test.step("fullscreen covers the header and keeps the live draft and selection", async () => {
|
||||
await selectComposerDraftRange(page, 4, 17);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
await expectComposerDraftSelection(page, 4, 17);
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
|
||||
await selectComposerDraftRange(page, 3, 12);
|
||||
});
|
||||
await test.step("exit restores the inline composer with the fullscreen edits", async () => {
|
||||
await exitComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await expect(composerLocator(page)).toHaveValue(
|
||||
"Edited fullscreen draft\nSecond line\nThird line",
|
||||
);
|
||||
await expectComposerDraftSelection(page, 3, 12);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
});
|
||||
await test.step("sending inline after a fullscreen edit clears the visible editor", async () => {
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
await writeCompactDraft(page, "Typed after the fullscreen cycle");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
});
|
||||
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");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await selectComposerDraftRange(page, 0, 5);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerDraftSelection(page, 0, 5);
|
||||
await expect(composerLocator(page)).toHaveValue("Short draft");
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
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);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await expect(composerLocator(page)).toHaveValue("Typed inside fullscreen");
|
||||
await writeCompactDraft(page, "Typed inside fullscreen, finished inline");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await writeCompactDraft(page, "One more inline message");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a caret inside an unpublished edit survives a breakpoint remount", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-remount-caret-",
|
||||
title: "Remount caret",
|
||||
});
|
||||
try {
|
||||
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 openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, "Short draft");
|
||||
await selectComposerDraftRange(page, 5, 5);
|
||||
// The draft owner publishes web edits after the next paint. A rotation inside that
|
||||
// window remounts the editor before the edit has reached the draft store; the paused
|
||||
// clock holds the page there instead of racing the frame.
|
||||
await pausePageClock(page);
|
||||
await composerLocator(page).press("X");
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await page.clock.resume();
|
||||
await expect(composerLocator(page)).toHaveValue("ShortX draft");
|
||||
await expectComposerDraftSelection(page, 6, 6);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await composerLocator(page).press("Y");
|
||||
await expect(composerLocator(page)).toHaveValue("ShortXY draft");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
@@ -250,15 +342,86 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
async function blurComposer(page: Page): Promise<void> {
|
||||
await composerLocator(page).blur();
|
||||
await expect(composerLocator(page)).not.toBeFocused();
|
||||
}
|
||||
|
||||
/** Mid-band at the given height and near the editor's bottom edge. */
|
||||
async function reservedBandPoints(
|
||||
page: Page,
|
||||
bandX: number,
|
||||
topY: number,
|
||||
): Promise<Array<{ x: number; y: number }>> {
|
||||
const editor = (await composerLocator(page).boundingBox())!;
|
||||
return [
|
||||
{ x: bandX, y: topY },
|
||||
{ x: bandX, y: editor.y + editor.height - 6 },
|
||||
];
|
||||
}
|
||||
|
||||
test("the reserved band beside the inline editor focuses it", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-band-focus-",
|
||||
title: "Composer band focus",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
const exitButton = page.getByRole("button", { name: "Exit fullscreen", exact: true });
|
||||
const fullscreenButton = page.getByRole("button", { name: "Fullscreen", exact: true });
|
||||
const longDraft = "Band first line\nBand second line\nBand third line";
|
||||
await writeCompactDraft(page, longDraft);
|
||||
await expect(fullscreenButton).toBeVisible();
|
||||
const button = (await fullscreenButton.boundingBox())!;
|
||||
const bandX = button.x + button.width / 2;
|
||||
|
||||
await test.step("with the fullscreen button shown, the band under it focuses the editor", async () => {
|
||||
for (const point of await reservedBandPoints(page, bandX, button.y + button.height + 10)) {
|
||||
await blurComposer(page);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(composerLocator(page)).toHaveValue(longDraft);
|
||||
await expect(exitButton).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("the button itself still toggles fullscreen", async () => {
|
||||
await fullscreenButton.click();
|
||||
await expectComposerFullscreenControls(page);
|
||||
await exitComposerFullscreen(page);
|
||||
await expect(exitButton).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toHaveValue(longDraft);
|
||||
});
|
||||
|
||||
await test.step("with no fullscreen button, the whole band still focuses the editor", async () => {
|
||||
await writeCompactDraft(page, "Short");
|
||||
await expect(fullscreenButton).toHaveCount(0);
|
||||
const editor = (await composerLocator(page).boundingBox())!;
|
||||
for (const point of await reservedBandPoints(page, bandX, editor.y + 6)) {
|
||||
await blurComposer(page);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(composerLocator(page)).toHaveValue("Short");
|
||||
await expect(exitButton).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
@@ -280,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();
|
||||
@@ -333,15 +495,28 @@ test("retained agent tabs preserve fullscreen editing and paste when switched aw
|
||||
const draft = "Live first line\nLive second line\nLive third line";
|
||||
await writeCompactDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
await selectComposerDraftRange(page, 4, 17);
|
||||
await activateRetainedComposerTab(page, first.title);
|
||||
// Fullscreen covers the compact header, so a phone leaves the tab by exiting first;
|
||||
// a desktop-width window still has the tab shortcut.
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await switchComposerTabWithKeyboard(page, "previous");
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||
// The exit action belongs to the active pane: Escape here leaves the hidden tab alone.
|
||||
await composerLocator(page).press("Escape");
|
||||
await switchComposerTabWithKeyboard(page, "next");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await switchComposerTabWithKeyboard(page, "previous");
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await writeCompactDraft(page, "Other tab draft");
|
||||
await shrinkVisibleViewport(page);
|
||||
await activateRetainedComposerTab(page, second.title);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await expectComposerDraftSelection(page, 4, 17);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
expect(await pasteComposerTestImage(page)).toBe(true);
|
||||
@@ -352,6 +527,36 @@ test("retained agent tabs preserve fullscreen editing and paste when switched aw
|
||||
}
|
||||
});
|
||||
|
||||
test("the compact sidebar over a fullscreen editor leaves the exit action alone", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-fullscreen-sidebar-",
|
||||
title: "Fullscreen under sidebar",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
const draft = "Line one\nLine two\nLine three";
|
||||
await writeCompactDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
// Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
|
||||
await swipeOpenMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
// The list covers the pane, so the editor's exit action is not armed.
|
||||
await page.keyboard.press("Escape");
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await exitComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("retained inline composers keep usable capacity through hidden tab geometry", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -408,30 +613,6 @@ async function panVisibleViewport(page: Page): Promise<void> {
|
||||
await cdp.detach();
|
||||
}
|
||||
|
||||
async function expectFullscreenInsideVisibleViewport(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.getByTestId("composer-fullscreen").evaluate((element) => {
|
||||
const viewport = window.visualViewport!;
|
||||
const frame = element.getBoundingClientRect();
|
||||
const send = element.querySelector('[aria-label="Send message"]')!.getBoundingClientRect();
|
||||
return {
|
||||
frameFits:
|
||||
Math.abs(frame.top - viewport.offsetTop) < 1 &&
|
||||
Math.abs(frame.left - viewport.offsetLeft) < 1 &&
|
||||
Math.abs(frame.height - viewport.height) < 1 &&
|
||||
Math.abs(frame.width - viewport.width) < 1,
|
||||
sendFits:
|
||||
send.top >= viewport.offsetTop &&
|
||||
send.bottom <= viewport.offsetTop + viewport.height &&
|
||||
send.left >= viewport.offsetLeft &&
|
||||
send.right <= viewport.offsetLeft + viewport.width,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.toEqual({ frameFits: true, sendFits: true });
|
||||
}
|
||||
|
||||
test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-visible-viewport-",
|
||||
@@ -443,11 +624,11 @@ test("fullscreen Send follows the visible viewport when it shrinks and pans", as
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await enterComposerFullscreen(page);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await shrinkVisibleViewport(page);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await panVisibleViewport(page);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
@@ -128,6 +129,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
@@ -206,21 +208,40 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
async function holdLegacyAgentCreation(page: Page, port: number) {
|
||||
const readers = await loadSessionMessageReaders();
|
||||
const arrived = Promise.withResolvers<void>();
|
||||
let created = Promise.withResolvers<void>();
|
||||
let createRequestId: string | undefined;
|
||||
let release = () => {};
|
||||
let holding = true;
|
||||
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (holding && readers.client(frame)?.type === "create_agent_request") {
|
||||
const message = readers.client(frame);
|
||||
if (message?.type === "create_agent_request") createRequestId = message.requestId;
|
||||
if (holding && message?.type === "create_agent_request") {
|
||||
holding = false;
|
||||
release = () => server.send(frame);
|
||||
arrived.resolve();
|
||||
} else server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
server.onMessage((frame) => {
|
||||
const message = readers.server(frame);
|
||||
if (
|
||||
message?.type === "status" &&
|
||||
message.payload.status === "agent_created" &&
|
||||
message.payload.requestId === createRequestId
|
||||
) {
|
||||
created.resolve();
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => arrived.promise,
|
||||
async waitForCreated() {
|
||||
// Agent-list membership includes initialization, before the initial prompt starts.
|
||||
await created.promise;
|
||||
created = Promise.withResolvers<void>();
|
||||
},
|
||||
release() {
|
||||
const send = release;
|
||||
release = () => {};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
addCustomModel,
|
||||
expectCustomModelRetained,
|
||||
openCustomModels,
|
||||
removeCustomModel,
|
||||
} from "../support/helpers/custom-models";
|
||||
|
||||
const longModelId = "custom-model-with-a-long-name-".repeat(6);
|
||||
const shortModelId = "custom-short";
|
||||
|
||||
for (const width of [1200, 390]) {
|
||||
test(`removes a long custom model at ${width}px without removing another model`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1200, height: 1000 });
|
||||
await openCustomModels(page);
|
||||
await addCustomModel(page, shortModelId);
|
||||
await addCustomModel(page, longModelId);
|
||||
|
||||
await test.step("remove the long model at the target width", async () => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await removeCustomModel(page, longModelId);
|
||||
await expectCustomModelRetained(page, shortModelId);
|
||||
});
|
||||
|
||||
await removeCustomModel(page, shortModelId);
|
||||
});
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
@@ -444,3 +444,25 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
|
||||
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("downloads a file over direct TCP with its original filename and bytes", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fileName = "rapport données.bin";
|
||||
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
|
||||
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const downloaded = await downloadFileFromExplorer(page, fileName);
|
||||
const url = new URL(downloaded.url());
|
||||
expect(url.protocol).toBe("http:");
|
||||
expect(url.port).toBe(getE2EDaemonPort());
|
||||
expect(url.pathname).toBe("/api/files/download");
|
||||
expect(url.searchParams.get("token")).toBeTruthy();
|
||||
expect(downloaded.suggestedFilename()).toBe(fileName);
|
||||
const savedFile = testInfo.outputPath(fileName);
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import {
|
||||
closeModelPicker,
|
||||
drillIntoProvider,
|
||||
openModelPicker,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectComposerVisible, expectComposerFocused } from "../support/helpers/composer";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHost, openSettingsHostSection } from "../support/helpers/settings";
|
||||
|
||||
// Use an existing repository linked to the signed-in user's Hoplite project.
|
||||
const cwd = process.env.E2E_HOPLITE_CWD ?? path.resolve(__dirname, "../../../..");
|
||||
|
||||
test.use({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
e2eDaemonConfig: { version: 1, agents: { providers: { hoplite: { enabled: true } } } },
|
||||
});
|
||||
|
||||
async function openHopliteSettings(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Hoplite provider details", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectEmptyGlobalCatalog(page: Page, client: DaemonClient): Promise<void> {
|
||||
const models = await client.listProviderModels("hoplite");
|
||||
expect(models.error).toBeNull();
|
||||
expect(models.models).toEqual([]);
|
||||
const snapshot = await client.getProvidersSnapshot();
|
||||
expect(snapshot.entries.find((entry) => entry.provider === "hoplite")).toMatchObject({
|
||||
status: "ready",
|
||||
models: [],
|
||||
});
|
||||
await expect(page.getByText("No models detected", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function refreshGlobalCatalog(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeEnabled({
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function openWorkspaceModels(page: Page, workspaceId: string): Promise<void> {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await expectComposerFocused(page);
|
||||
await openModelPicker(page);
|
||||
await page.getByTestId("sheet-header-back").click();
|
||||
await drillIntoProvider(page, "hoplite");
|
||||
}
|
||||
|
||||
test("real Hoplite has an empty global catalog and discovers models in its linked workspace", async ({
|
||||
page,
|
||||
e2eWorkerClient,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "hoplite-discovery" });
|
||||
const created = await e2eWorkerClient.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
title: "Hoplite discovery QA",
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
const workspace = created.workspace!;
|
||||
try {
|
||||
await test.step("global settings succeed with no models, including refresh", async () => {
|
||||
await openHopliteSettings(page);
|
||||
await expectEmptyGlobalCatalog(page, client);
|
||||
await refreshGlobalCatalog(page);
|
||||
await expectEmptyGlobalCatalog(page, client);
|
||||
await page.screenshot({ path: testInfo.outputPath("hoplite-global-empty.png") });
|
||||
});
|
||||
|
||||
await test.step("the linked workspace discovers real models", async () => {
|
||||
const models = await client.listProviderModels("hoplite", { cwd });
|
||||
expect(models.error).toBeNull();
|
||||
expect(models.models!.length).toBeGreaterThan(0);
|
||||
await testInfo.attach("workspace-models", {
|
||||
body: JSON.stringify(models, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await openWorkspaceModels(page, workspace.id);
|
||||
await expect(page.getByText(models.models![0]!.label, { exact: true }).last()).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("hoplite-workspace-models.png") });
|
||||
await closeModelPicker(page);
|
||||
});
|
||||
|
||||
await test.step("workspace discovery does not populate the global catalog", async () => {
|
||||
await openHopliteSettings(page);
|
||||
await expectEmptyGlobalCatalog(page, client);
|
||||
await refreshGlobalCatalog(page);
|
||||
await expectEmptyGlobalCatalog(page, client);
|
||||
const models = await client.listProviderModels("hoplite", { cwd });
|
||||
expect(models.error).toBeNull();
|
||||
expect(models.models!.length).toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await e2eWorkerClient.removeProject(workspace.projectId);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
expectComposerFullscreenControls,
|
||||
resizeComposerViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectTerminalLaunchSelected,
|
||||
@@ -145,3 +156,117 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function expectNewWorkspaceHeaderHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectNewWorkspaceHeaderVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("menu-button").filter({ visible: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen fills the content area top to bottom and out to the right edge (a pinned
|
||||
* sidebar keeps its column), covers the form, and shows only exit and the mode's submit.
|
||||
*/
|
||||
async function expectNewWorkspaceFullscreen(page: Page, submitLabel: string): Promise<void> {
|
||||
const frame = page.getByTestId("composer-fullscreen");
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
|
||||
await expect(frame.getByRole("button")).toHaveCount(2);
|
||||
await expect(frame.getByRole("button", { name: submitLabel })).toBeVisible();
|
||||
await expectNewWorkspaceHeaderHidden(page);
|
||||
const picker = (await page.getByRole("button", { name: "Workspace project" }).boundingBox())!;
|
||||
await expect
|
||||
.poll(() =>
|
||||
frame.evaluate(
|
||||
(element, point) => {
|
||||
const viewport = window.visualViewport!;
|
||||
const box = element.getBoundingClientRect();
|
||||
const covering = document.elementFromPoint(point.x, point.y);
|
||||
return {
|
||||
fillsHeight:
|
||||
Math.abs(box.top - viewport.offsetTop) < 1 &&
|
||||
Math.abs(box.height - viewport.height) < 1,
|
||||
reachesRightEdge:
|
||||
Math.abs(box.right - (viewport.offsetLeft + viewport.width)) < 1 &&
|
||||
box.left >= viewport.offsetLeft,
|
||||
coversForm: element.contains(covering),
|
||||
};
|
||||
},
|
||||
{ x: picker.x + picker.width / 2, y: picker.y + picker.height / 2 },
|
||||
),
|
||||
)
|
||||
.toEqual({ fillsHeight: true, reachesRightEdge: true, coversForm: true });
|
||||
}
|
||||
|
||||
test.describe("New workspace: fullscreen composer across the compact breakpoint", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-fullscreen-" });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("chat fullscreen survives widening and terminal fullscreen fills the phone", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
|
||||
await test.step("chat: fullscreen covers the centered form and exits back into it", async () => {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
const draft = "Chat first line\nChat second line\nChat third line";
|
||||
await fillNewWorkspaceDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectNewWorkspaceHeaderHidden(page);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectNewWorkspaceFullscreen(page, "Create");
|
||||
// The desktop form's own import button is dropped from the fullscreen slot.
|
||||
await expect(
|
||||
page.getByTestId("new-workspace-import-session").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await composerLocator(page).press("End");
|
||||
await composerLocator(page).pressSequentially(" wide");
|
||||
await exitComposerFullscreen(page);
|
||||
await expectNewWorkspaceHeaderVisible(page);
|
||||
await expect(projectPicker).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("new-workspace-import-session").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expectNewWorkspaceDraft(page, `${draft} wide`);
|
||||
});
|
||||
|
||||
// Terminal mode and its prompt are screen state, and the shell remounts the screen
|
||||
// at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone
|
||||
// layout only.
|
||||
await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
// The compact launch control opens a sheet, not the desktop menu.
|
||||
await launchTrigger(page).click();
|
||||
const option = page.getByRole("menuitem").filter({ hasText: PROMPT_PROFILE.name });
|
||||
await option.click();
|
||||
await expect(option).not.toBeVisible();
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
const prompt = "Prompt first line\nPrompt second line\nPrompt third line";
|
||||
await fillTerminalPrompt(page, prompt);
|
||||
await enterComposerFullscreen(page);
|
||||
await expect(terminalPromptInput(page)).toBeVisible();
|
||||
await expectNewWorkspaceFullscreen(page, "Launch");
|
||||
await exitComposerFullscreen(page);
|
||||
await expectNewWorkspaceHeaderVisible(page);
|
||||
await expect(projectPicker).toBeVisible();
|
||||
await expect(terminalPromptInput(page)).toHaveValue(prompt);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
expectQuestionDismissEnabled,
|
||||
expectQuestionHidden,
|
||||
expectQuestionNavigationEnabled,
|
||||
expectQuestionOptionPreview,
|
||||
expectQuestionOptionSelected,
|
||||
expectQuestionPrimaryActionDisabled,
|
||||
expectQuestionPrimaryActionEnabled,
|
||||
@@ -61,6 +62,10 @@ test.describe("Question prompt pagination", () => {
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
|
||||
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionPreview(page, {
|
||||
option: "Behind feature flag",
|
||||
preview: 'flags.enabled("new-surface")',
|
||||
});
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 3,
|
||||
|
||||
@@ -100,6 +100,40 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function installReversedUsageRefresh(page: Page) {
|
||||
const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)];
|
||||
const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)];
|
||||
return installUsageReportsFixture(page, {
|
||||
lists: [(request) => (request.forceRefresh ? refreshed : initial)],
|
||||
});
|
||||
}
|
||||
|
||||
async function openUsageSummaryAtWidth(page: Page, width: number) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
if (width === 390) await openCompactSidebar(page);
|
||||
await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]);
|
||||
await openUsageFromItem(page);
|
||||
}
|
||||
|
||||
async function expectUsageProviderOrder(page: Page, summary: string[]) {
|
||||
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([
|
||||
"opencode plan",
|
||||
"openai plan",
|
||||
]);
|
||||
await expectPinnedUsage(page, summary);
|
||||
}
|
||||
|
||||
async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixture) {
|
||||
const requestCount = usage.listRequests().length;
|
||||
await refreshAllUsage(page);
|
||||
await usage.waitForListRequests(requestCount + 1);
|
||||
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(group.getByText("41%", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
test.describe("usage modal", () => {
|
||||
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
|
||||
page,
|
||||
@@ -152,6 +186,21 @@ test.describe("usage modal", () => {
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
test(`keeps sidebar and modal provider order across a reversed refresh at width ${width}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const usage = await installReversedUsageRefresh(page);
|
||||
await openUsageSummaryAtWidth(page, width);
|
||||
await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]);
|
||||
await qaScreenshot(page, `usage-order-${width}-before-refresh`);
|
||||
|
||||
await refreshUsageAndWaitForReports(page, usage);
|
||||
await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]);
|
||||
await qaScreenshot(page, `usage-order-${width}-after-refresh`);
|
||||
});
|
||||
}
|
||||
|
||||
test("refreshes one report from its card", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
pinRow,
|
||||
scrollUsage,
|
||||
scrollUsageToWindow,
|
||||
setSummaryInSidebar,
|
||||
openUsageOptions,
|
||||
showUsageAs,
|
||||
@@ -146,7 +146,9 @@ test.describe("Usage item", () => {
|
||||
compact: { viewport: COMPACT, showSidebar: openCompactSidebar },
|
||||
};
|
||||
for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) {
|
||||
test(`long usage reports scroll to the final window on ${size}`, async ({ page }) => {
|
||||
test(`long usage reports scroll between their first and last windows on ${size}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const source = await installTallUsageSource();
|
||||
try {
|
||||
@@ -161,10 +163,10 @@ test.describe("Usage item", () => {
|
||||
await waitForSettledPosition(content);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
await qaScreenshot(page, `${size}-usage-scroll-top`);
|
||||
await scrollUsage(page, 5_000);
|
||||
await scrollUsageToWindow(page, "Window 20");
|
||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
||||
await qaScreenshot(page, `${size}-usage-scroll-bottom`);
|
||||
await scrollUsage(page, -5_000);
|
||||
await scrollUsageToWindow(page, "Window 1");
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
switchWorkspaceViaSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
async function readVisibleWorkspaceOrder(page: Page): Promise<string[]> {
|
||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||
await expect(rows).toHaveCount(12);
|
||||
return rows.evaluateAll(
|
||||
(elements, prefix) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
|
||||
`sidebar-workspace-row-${getServerId()}:`,
|
||||
);
|
||||
}
|
||||
|
||||
async function navigateWorkspace(
|
||||
page: Page,
|
||||
direction: "next" | "previous",
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
|
||||
}
|
||||
|
||||
test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const project = await seedWorkspace({
|
||||
repoPrefix: "relative-navigation-",
|
||||
title: "Workspace 01",
|
||||
});
|
||||
try {
|
||||
for (let index = 2; index <= 12; index += 1) {
|
||||
const created = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
title: `Workspace ${String(index).padStart(2, "0")}`,
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1100 });
|
||||
await gotoAppShell(page);
|
||||
const order = await readVisibleWorkspaceOrder(page);
|
||||
|
||||
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||
await navigateWorkspace(page, "next", order[9]);
|
||||
await navigateWorkspace(page, "previous", order[8]);
|
||||
});
|
||||
|
||||
await test.step("wrapping uses the true first and last visible workspace", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||
await navigateWorkspace(page, "previous", order[11]);
|
||||
await navigateWorkspace(page, "next", order[0]);
|
||||
});
|
||||
|
||||
await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
|
||||
await page.keyboard.press("Alt+9");
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: order[8],
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
# Native question option preview
|
||||
|
||||
`native-question-option-preview.android.ad` checks that every line of a long option preview is reachable on Android. Before replay, open a mock agent prompted with `emit synthetic questions` so its question card shows the first question, on a phone-sized emulator (validated on a 1080×1920 API 35 AVD). The flow opens the `rollout` question, drags slowly up from inside the "Behind feature flag" preview, and asserts the preview's last line is visible.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad --platform android --serial emulator-5554
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "label=\"rollout\"" 20000
|
||||
press "label=\"rollout\""
|
||||
wait "id=\"question-form-option-preview\"" 10000
|
||||
gesture pan 570 1120 0 -440 600
|
||||
is visible "text=\"// LAST PREVIEW LINE\""
|
||||
close
|
||||
@@ -90,3 +90,26 @@ export async function expectNewWorkspaceForAddedProject(
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function navigateAddProjectResults(
|
||||
page: Page,
|
||||
key: "ArrowDown" | "ArrowUp",
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
for (let index = 0; index < count; index += 1) await page.keyboard.press(key);
|
||||
}
|
||||
|
||||
export async function expectAddProjectResultVisible(page: Page, text: string): Promise<void> {
|
||||
const results = page.getByTestId("add-project-flow-results");
|
||||
const row = results.getByRole("button").filter({ has: page.getByText(text, { exact: true }) });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const viewport = await results.boundingBox();
|
||||
const bounds = await row.boundingBox();
|
||||
if (!viewport || !bounds) return false;
|
||||
return (
|
||||
bounds.y >= viewport.y - 1 && bounds.y + bounds.height <= viewport.y + viewport.height + 1
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
@@ -355,6 +361,12 @@ export async function resizeComposerViewport(
|
||||
short: { width: 390, height: 390 },
|
||||
};
|
||||
await page.setViewportSize(sizes[orientation]);
|
||||
// Before the app has loaded there is no layout to wait for.
|
||||
if (page.url() === "about:blank") return;
|
||||
// Crossing the compact breakpoint remounts the shell and the editor. Anything that
|
||||
// follows (a shortcut, a locator) belongs to the layout the new size implies, so
|
||||
// wait for that layout before returning.
|
||||
await expect(page.getByTestId("compact-shell")).toHaveCount(orientation === "landscape" ? 0 : 1);
|
||||
}
|
||||
export async function selectComposerDraftRange(
|
||||
page: Page,
|
||||
@@ -390,6 +402,33 @@ export async function expectComposerFullscreenControls(page: Page): Promise<void
|
||||
await expect(page.getByTestId("composer-fullscreen").getByRole("button")).toHaveCount(2);
|
||||
await expect(composerInput(page)).toHaveCount(1);
|
||||
}
|
||||
/** The fullscreen frame and its submit button sit inside the browser's visible viewport. */
|
||||
export async function expectComposerFullscreenFitsVisibleViewport(
|
||||
page: Page,
|
||||
submitLabel = "Send message",
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.getByTestId("composer-fullscreen").evaluate((element, label) => {
|
||||
const viewport = window.visualViewport!;
|
||||
const frame = element.getBoundingClientRect();
|
||||
const send = element.querySelector(`[aria-label="${label}"]`)!.getBoundingClientRect();
|
||||
return {
|
||||
frameFits:
|
||||
Math.abs(frame.top - viewport.offsetTop) < 1 &&
|
||||
Math.abs(frame.left - viewport.offsetLeft) < 1 &&
|
||||
Math.abs(frame.height - viewport.height) < 1 &&
|
||||
Math.abs(frame.width - viewport.width) < 1,
|
||||
sendFits:
|
||||
send.top >= viewport.offsetTop &&
|
||||
send.bottom <= viewport.offsetTop + viewport.height &&
|
||||
send.left >= viewport.offsetLeft &&
|
||||
send.right <= viewport.offsetLeft + viewport.width,
|
||||
};
|
||||
}, submitLabel),
|
||||
)
|
||||
.toEqual({ frameFits: true, sendFits: true });
|
||||
}
|
||||
export async function sendComposerWithButton(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
}
|
||||
@@ -405,6 +444,22 @@ export async function configureComposerQueueSend(page: Page): Promise<void> {
|
||||
/** Drive the real tab chooser's commands even when fullscreen covers its pointer surface.
|
||||
* This exercises retained activity changes, not a claim that covered chrome is clickable.
|
||||
*/
|
||||
/** Needs a desktop-width viewport: compact layouts have no keyboard shortcuts. */
|
||||
export async function switchComposerTabWithKeyboard(
|
||||
page: Page,
|
||||
direction: "previous" | "next",
|
||||
): Promise<void> {
|
||||
await page.keyboard.press(
|
||||
direction === "previous" ? "Alt+Shift+BracketLeft" : "Alt+Shift+BracketRight",
|
||||
);
|
||||
}
|
||||
export async function expectWorkspaceHeaderHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toHaveCount(0);
|
||||
}
|
||||
export async function expectWorkspaceHeaderVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function activateRetainedComposerTab(page: Page, title: string): Promise<void> {
|
||||
await page
|
||||
.getByTestId("workspace-tab-switcher-trigger")
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function openCustomModels(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model", exact: true }).click();
|
||||
await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
|
||||
await page
|
||||
.getByTestId("add-custom-model-sheet")
|
||||
.getByRole("button", { name: "Add", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
|
||||
await expect(remove).toBeInViewport({ ratio: 1 });
|
||||
await remove.click();
|
||||
await expect(remove).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Download, type Page } from "@playwright/test";
|
||||
import { openFilesPanel } from "./workspace-tabs";
|
||||
|
||||
function fileExplorerTree(page: Page) {
|
||||
@@ -39,3 +39,10 @@ export async function expectFileTabOpen(page: Page, filePath: string): Promise<v
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadFileFromExplorer(page: Page, fileName: string): Promise<Download> {
|
||||
await fileExplorerEntry(page, fileName).click({ button: "right" });
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,15 @@ export async function expectQuestionOptionSelected(page: Page, option: string):
|
||||
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
|
||||
}
|
||||
|
||||
export async function expectQuestionOptionPreview(
|
||||
page: Page,
|
||||
input: { option: string; preview: string },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
questionOption(page, input.option).getByTestId("question-form-option-preview"),
|
||||
).toContainText(input.preview);
|
||||
}
|
||||
|
||||
export async function openQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
|
||||
@@ -370,7 +370,7 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
|
||||
|
||||
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await findAcpCatalogProvider(page, providerName);
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.getByRole("button", { name: `Add ${providerName}`, exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectProviderInstalledInSettings(
|
||||
|
||||
+30
-25
@@ -1,31 +1,37 @@
|
||||
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 dialog starts below the sheet's drag handle. */
|
||||
const SHEET_HANDLE_MAX_HEIGHT = 32;
|
||||
/** The header starts below the sheet's drag indicator, inside the handle. */
|
||||
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 = sheetHeader(page, label);
|
||||
const body = sheet(page, label);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await sheet.boundingBox();
|
||||
if (!box) return null;
|
||||
const top = Math.round(box.y);
|
||||
const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]);
|
||||
if (!headerBox || !bodyBox) return null;
|
||||
const top = Math.round(headerBox.y);
|
||||
return {
|
||||
dockedToBottom: Math.round(box.y + box.height) === viewportHeight,
|
||||
topAtSnap: top >= snapTop && top <= snapTop + SHEET_HANDLE_MAX_HEIGHT,
|
||||
dockedToBottom: Math.round(bodyBox.y + bodyBox.height) === viewportHeight,
|
||||
topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_MAX_HEIGHT,
|
||||
};
|
||||
})
|
||||
.toEqual({ dockedToBottom: true, topAtSnap: true });
|
||||
@@ -37,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);
|
||||
@@ -67,15 +69,18 @@ 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();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function tapWorkspaceSheetBackdrop(page: Page): Promise<void> {
|
||||
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
|
||||
export async function tapSheetBackdrop(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||
.last()
|
||||
.click({ position: { x: 10, y: 10 } });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -544,7 +544,7 @@ async function readTimelineViewport(page: Page): Promise<TimelineViewportSnapsho
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForTimelineGeometryToSettle(page: Page): Promise<void> {
|
||||
export async function waitForTimelineGeometryToSettle(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.evaluate(
|
||||
(element) =>
|
||||
|
||||
@@ -297,11 +297,7 @@ export function findScrollJumps(frames: ScrollFrame[]) {
|
||||
// Follow that intended row, not text now below the image.
|
||||
const recent = frames.findLast((frame) => frame.at <= previous.at - 100);
|
||||
const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0);
|
||||
const frameScroll = Math.max(0, previous.scrollTop - current.scrollTop);
|
||||
const availableScroll = Math.min(frameScroll, wheelBudget, previous.scrollTop);
|
||||
const readingLine = 8 - availableScroll;
|
||||
const currentRows = new Map(current.rows.map((row) => [row.id, row]));
|
||||
const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine);
|
||||
const anchor = previous.anchor;
|
||||
const before = currentRows.has(anchor)
|
||||
? previous.rows.find((row) => row.id === anchor)!
|
||||
@@ -316,25 +312,33 @@ export function findScrollJumps(frames: ScrollFrame[]) {
|
||||
// A busy main thread can deliver wheel movement hundreds of milliseconds
|
||||
// after its event. Only assert idle stability after the driver stops input.
|
||||
const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250;
|
||||
// Wheel events can precede their scroll update, or include input already
|
||||
// applied in earlier frames. Locate the reader with this frame's scroll,
|
||||
// bounded by recent input. Growth below it can move the old anchor without
|
||||
// moving the reading line.
|
||||
const readerFollowsInput =
|
||||
intendedRow &&
|
||||
currentRows.has(intendedRow.id) &&
|
||||
Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32;
|
||||
const enteredRowGrowth = readerFollowsInput
|
||||
// scrollTop also includes virtualizer estimate corrections. Locate the row
|
||||
// entered by wheel input from its viewport movement instead. Its old height
|
||||
// identifies the reader before a simultaneous image resize takes effect.
|
||||
const intendedRow = previous.rows.find((row) => {
|
||||
const next = currentRows.get(row.id);
|
||||
if (!next || idle || wheelBudget <= 0) return false;
|
||||
const travel = next.top - row.top;
|
||||
return (
|
||||
travel >= -8 &&
|
||||
travel <= Math.min(wheelBudget, previous.scrollTop) + 32 &&
|
||||
(next.top <= 8 || current.scrollTop === 0) &&
|
||||
next.top + row.height > 8
|
||||
);
|
||||
});
|
||||
const enteredRowResize = intendedRow
|
||||
? previous.rows.reduce((growth, row) => {
|
||||
if (row.top < intendedRow.top || row.top >= before.top) return growth;
|
||||
const height = currentRows.get(row.id)?.height ?? row.height;
|
||||
return growth + Math.max(0, height - row.height);
|
||||
return growth + height - row.height;
|
||||
}, 0)
|
||||
: 0;
|
||||
const excessForward = movement > wheelBudget + enteredRowGrowth + 32;
|
||||
const readingMovement = movement - enteredRowResize;
|
||||
const excessForward = readingMovement > wheelBudget + 32;
|
||||
// Upward wheel input moves the same text DOWN the viewport. A negative move
|
||||
// is a reversal, independent of legitimate scrollTop compensation on prepend.
|
||||
if (movement >= -8 && (!idle || Math.abs(movement) <= 8) && !excessForward) return [];
|
||||
if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
frame: index,
|
||||
|
||||
@@ -212,6 +212,14 @@ export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
|
||||
await page.mouse.wheel(0, deltaY);
|
||||
}
|
||||
|
||||
/** Reach a window with the wheel even when other reports follow its card. */
|
||||
export async function scrollUsageToWindow(page: Page, label: string): Promise<void> {
|
||||
const target = await usageModal(page).getByText(label, { exact: true }).boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
if (!target || !viewport) throw new Error("Usage window must exist before scrolling to it.");
|
||||
await scrollUsage(page, target.y + target.height / 2 - viewport.height / 2);
|
||||
}
|
||||
|
||||
/** The modal's own Close button. */
|
||||
export async function closeUsage(page: Page): Promise<void> {
|
||||
await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click();
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { ReactNode, RefObject } from "react";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamViewportHandle } from "../strategy";
|
||||
|
||||
export interface MountedMessageIds {
|
||||
has(messageId: string): boolean;
|
||||
}
|
||||
export interface ChatFindProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
@@ -9,7 +12,7 @@ export interface ChatFindProps {
|
||||
items: StreamItem[];
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
revealLoadedMessage(messageId: string): boolean;
|
||||
visibleMessageIds: ReadonlySet<string>;
|
||||
visibleMessageIds: MountedMessageIds;
|
||||
children: ReactNode;
|
||||
}
|
||||
export interface ChatFindExpansionProps {
|
||||
|
||||
@@ -178,7 +178,7 @@ describe("useChatOutline", () => {
|
||||
timelineEpoch,
|
||||
enabled,
|
||||
viewportRef,
|
||||
visibleMessageIds: new Set(),
|
||||
mountedHistoryMessageIds: new Set(),
|
||||
onJumpError: vi.fn(),
|
||||
}),
|
||||
{
|
||||
@@ -278,7 +278,7 @@ describe("useChatOutline", () => {
|
||||
},
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ visibleMessageIds }) =>
|
||||
({ mountedHistoryMessageIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
@@ -288,10 +288,10 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
visibleMessageIds,
|
||||
mountedHistoryMessageIds,
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
{ initialProps: { visibleMessageIds: new Set<string>() } },
|
||||
{ initialProps: { mountedHistoryMessageIds: new Set<string>() } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
@@ -299,7 +299,7 @@ describe("useChatOutline", () => {
|
||||
expect(revealLoadedMessage).toHaveBeenCalledWith("older-prompt");
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ visibleMessageIds: new Set(["older-prompt"]) });
|
||||
rerender({ mountedHistoryMessageIds: new Set(["older-prompt"]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
|
||||
});
|
||||
|
||||
@@ -327,7 +327,7 @@ describe("useChatOutline", () => {
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ tail, visibleMessageIds }) =>
|
||||
({ tail, mountedHistoryMessageIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
@@ -337,13 +337,13 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
visibleMessageIds,
|
||||
mountedHistoryMessageIds,
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
tail: [] as (typeof fetchedPrompt)[],
|
||||
visibleMessageIds: new Set<string>(),
|
||||
mountedHistoryMessageIds: new Set<string>(),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -352,11 +352,11 @@ describe("useChatOutline", () => {
|
||||
act(() => result.current.jumpToPrompt(1));
|
||||
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
|
||||
|
||||
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set<string>() });
|
||||
rerender({ tail: [fetchedPrompt], mountedHistoryMessageIds: new Set<string>() });
|
||||
await waitFor(() => expect(revealLoadedMessage).toHaveBeenCalledWith(fetchedPrompt.id));
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set([fetchedPrompt.id]) });
|
||||
rerender({ tail: [fetchedPrompt], mountedHistoryMessageIds: new Set([fetchedPrompt.id]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
|
||||
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
|
||||
await act(async () => fetch.resolve());
|
||||
@@ -392,7 +392,7 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef: { current: viewport },
|
||||
onJumpError: vi.fn(),
|
||||
visibleMessageIds: new Set([livePrompt.id]),
|
||||
mountedHistoryMessageIds: new Set<string>(),
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/daemon-client";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -33,7 +33,8 @@ export interface UseChatOutlineInput {
|
||||
enabled: boolean;
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
onJumpError: () => void;
|
||||
visibleMessageIds?: ReadonlySet<string>;
|
||||
// Message ids of the history the viewport has mounted. Live head messages are always mounted.
|
||||
mountedHistoryMessageIds?: ReadonlySet<string>;
|
||||
revealLoadedMessage?: (messageId: string) => boolean;
|
||||
}
|
||||
|
||||
@@ -44,6 +45,24 @@ export interface ChatOutline {
|
||||
reportReadingPosition: (seq: number | null) => void;
|
||||
}
|
||||
|
||||
function latestPromptSeqIn(items: StreamItem[], timelineEpoch: string | null): number {
|
||||
let latest = -1;
|
||||
for (const item of items) {
|
||||
if (item.kind === "user_message" && item.timelineCursor?.epoch === timelineEpoch) {
|
||||
latest = Math.max(latest, item.timelineCursor.seq);
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function findLoadedItem(
|
||||
tail: StreamItem[],
|
||||
head: StreamItem[],
|
||||
predicate: (item: StreamItem) => boolean,
|
||||
): StreamItem | undefined {
|
||||
return tail.find(predicate) ?? head.find(predicate);
|
||||
}
|
||||
|
||||
export function useChatOutline({
|
||||
agentId,
|
||||
serverId,
|
||||
@@ -53,7 +72,7 @@ export function useChatOutline({
|
||||
enabled,
|
||||
viewportRef,
|
||||
onJumpError,
|
||||
visibleMessageIds,
|
||||
mountedHistoryMessageIds,
|
||||
revealLoadedMessage,
|
||||
}: UseChatOutlineInput): ChatOutline {
|
||||
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
|
||||
@@ -62,18 +81,19 @@ export function useChatOutline({
|
||||
const readingSeqRef = useRef<number | null>(null);
|
||||
const nextJumpRequestIdRef = useRef(0);
|
||||
const nextIndexRequestIdRef = useRef(0);
|
||||
const loadedItems = useMemo(() => [...tail, ...(head ?? NO_STREAM_ITEMS)], [head, tail]);
|
||||
// A disabled outline (every native viewport) must not pay for the timeline per delta.
|
||||
const loadedTail = enabled ? tail : NO_STREAM_ITEMS;
|
||||
const loadedHead = enabled ? (head ?? NO_STREAM_ITEMS) : NO_STREAM_ITEMS;
|
||||
const prompts = enabled ? (index?.prompts ?? NO_PROMPTS) : NO_PROMPTS;
|
||||
|
||||
// The viewed timeline already owns live delivery and reconnect catch-up. Its complete
|
||||
// loaded items (including rows outside the mounted window) invalidate the prompt index.
|
||||
const latestPromptSeq = loadedItems.reduce(
|
||||
(latest, item) =>
|
||||
item.kind === "user_message" && item.timelineCursor?.epoch === timelineEpoch
|
||||
? Math.max(latest, item.timelineCursor.seq)
|
||||
: latest,
|
||||
-1,
|
||||
// Only the head changes per streamed delta, so the tail's contribution is memoized.
|
||||
const tailPromptSeq = useMemo(
|
||||
() => latestPromptSeqIn(loadedTail, timelineEpoch),
|
||||
[loadedTail, timelineEpoch],
|
||||
);
|
||||
const latestPromptSeq = Math.max(tailPromptSeq, latestPromptSeqIn(loadedHead, timelineEpoch));
|
||||
|
||||
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
|
||||
|
||||
@@ -135,10 +155,18 @@ export function useChatOutline({
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingJump === null) return;
|
||||
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
|
||||
const target = findLoadedItem(
|
||||
loadedTail,
|
||||
loadedHead,
|
||||
(item) => item.timelineCursor?.seq === pendingJump.seq,
|
||||
);
|
||||
if (target) {
|
||||
if (pendingJump.hasScrolled) return;
|
||||
if (visibleMessageIds?.has(target.id) === false) {
|
||||
const isMounted =
|
||||
mountedHistoryMessageIds === undefined ||
|
||||
mountedHistoryMessageIds.has(target.id) ||
|
||||
loadedHead.includes(target);
|
||||
if (!isMounted) {
|
||||
revealLoadedMessage?.(target.id);
|
||||
return;
|
||||
}
|
||||
@@ -150,40 +178,48 @@ export function useChatOutline({
|
||||
return;
|
||||
}
|
||||
if (pendingJump.fetchSettled) setPendingJump(null);
|
||||
}, [loadedItems, pendingJump, revealLoadedMessage, viewportRef, visibleMessageIds]);
|
||||
}, [
|
||||
loadedHead,
|
||||
loadedTail,
|
||||
mountedHistoryMessageIds,
|
||||
pendingJump,
|
||||
revealLoadedMessage,
|
||||
viewportRef,
|
||||
]);
|
||||
|
||||
const jumpToPrompt = useCallback(
|
||||
(seq: number) => {
|
||||
nextJumpRequestIdRef.current += 1;
|
||||
setPendingJump(null);
|
||||
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
|
||||
if (loaded) {
|
||||
if (revealLoadedMessage?.(loaded.id)) {
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
|
||||
return;
|
||||
}
|
||||
viewportRef.current?.scrollToMessage?.(loaded.id);
|
||||
const jumpToPrompt = useStableEvent((seq: number) => {
|
||||
nextJumpRequestIdRef.current += 1;
|
||||
setPendingJump(null);
|
||||
const loaded = findLoadedItem(
|
||||
loadedTail,
|
||||
loadedHead,
|
||||
(item) => item.timelineCursor?.seq === seq,
|
||||
);
|
||||
if (loaded) {
|
||||
if (revealLoadedMessage?.(loaded.id)) {
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
|
||||
return;
|
||||
}
|
||||
if (!index) return;
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false });
|
||||
void getHostRuntimeStore()
|
||||
.fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq }))
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Failed to load a Chat outline window", error);
|
||||
onJumpError();
|
||||
})
|
||||
.finally(() => {
|
||||
setPendingJump((current) => {
|
||||
if (current?.requestId !== requestId) return current;
|
||||
return { ...current, fetchSettled: true };
|
||||
});
|
||||
viewportRef.current?.scrollToMessage?.(loaded.id);
|
||||
return;
|
||||
}
|
||||
if (!index) return;
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false });
|
||||
void getHostRuntimeStore()
|
||||
.fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq }))
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Failed to load a Chat outline window", error);
|
||||
onJumpError();
|
||||
})
|
||||
.finally(() => {
|
||||
setPendingJump((current) => {
|
||||
if (current?.requestId !== requestId) return current;
|
||||
return { ...current, fetchSettled: true };
|
||||
});
|
||||
},
|
||||
[agentId, index, loadedItems, onJumpError, revealLoadedMessage, serverId, viewportRef],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { setFlagsFromString } from "node:v8";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
function revealHistory(tail: StreamItem[]): WeakRef<StreamItem[]>[] {
|
||||
const windows: WeakRef<StreamItem[]>[] = [];
|
||||
for (let start = tail.length - 20; start >= 20; start -= 20) {
|
||||
const model = buildAgentStreamRenderModel({
|
||||
tail,
|
||||
head: [],
|
||||
historyStart: start,
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
isTurnActive: false,
|
||||
activeTurnStartedAt: null,
|
||||
});
|
||||
windows.push(new WeakRef(model.history));
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
it("releases obsolete history windows and their source timeline", async () => {
|
||||
setFlagsFromString("--expose_gc");
|
||||
const gc = runInNewContext("gc") as () => void;
|
||||
setFlagsFromString("--no-expose_gc");
|
||||
async function collect() {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
gc();
|
||||
}
|
||||
}
|
||||
let tail: StreamItem[] = Array.from({ length: 1000 }, (_, i) => ({
|
||||
kind: i % 2 ? "assistant_message" : "user_message",
|
||||
id: `row-${i}`,
|
||||
text: "text",
|
||||
timestamp: new Date(1700000000000 + i * 1000),
|
||||
}));
|
||||
const windows = revealHistory(tail);
|
||||
await collect();
|
||||
expect(windows.filter((ref) => ref.deref() !== undefined)).toHaveLength(1);
|
||||
|
||||
windows.push(...revealHistory(tail));
|
||||
await collect();
|
||||
expect(windows.filter((ref) => ref.deref() !== undefined)).toHaveLength(1);
|
||||
|
||||
tail = [];
|
||||
await collect();
|
||||
expect(windows.every((ref) => ref.deref() === undefined)).toBe(true);
|
||||
});
|
||||
@@ -240,4 +240,47 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
|
||||
expect(model.turnTiming.byAssistantId.size).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["web", "native"] as const)(
|
||||
"reuses rendered history and timing across live-head updates inside a history window on %s",
|
||||
(platform) => {
|
||||
const at = (seed: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, seed));
|
||||
const tail: StreamItem[] = [];
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
tail.push(
|
||||
{ ...userMessage(`u${index}`, 0), timestamp: at(index * 2) },
|
||||
{ ...assistantMessage(`a${index}`, 0), timestamp: at(index * 2 + 1) },
|
||||
);
|
||||
}
|
||||
const live = (text: string): StreamItem => ({
|
||||
kind: "assistant_message",
|
||||
id: "live",
|
||||
text,
|
||||
timestamp: at(81),
|
||||
});
|
||||
const build = (head: StreamItem[]) =>
|
||||
buildAgentStreamRenderModel({
|
||||
isTurnActive: true,
|
||||
activeTurnStartedAt: tail.at(-2)?.timestamp ?? null,
|
||||
tail,
|
||||
head,
|
||||
platform,
|
||||
isMobileBreakpoint: false,
|
||||
historyStart: 60,
|
||||
});
|
||||
|
||||
const first = build([live("live")]);
|
||||
const second = build([live("live grows")]);
|
||||
|
||||
expect(second.history).toBe(first.history);
|
||||
expect(second.segments.historyMounted).toBe(first.segments.historyMounted);
|
||||
expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId);
|
||||
expect(first.history).toHaveLength(20);
|
||||
expect(first.turnTiming.byAssistantId.has("a29")).toBe(false);
|
||||
expect(first.turnTiming.byAssistantId.get("a38")).toEqual({
|
||||
completedAt: tail[77]?.timestamp,
|
||||
durationMs: 1000,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -53,11 +53,29 @@ const splitHistoryCache = new WeakMap<
|
||||
StreamItem[],
|
||||
Map<string, Pick<AgentStreamRenderModel, "history" | "segments">>
|
||||
>();
|
||||
const turnTimingCache = new WeakMap<
|
||||
// A streamed delta rebuilds the model with the same tail. Everything below is keyed on
|
||||
// the rendered tail's identity, so the slice has to be reused, not recomputed.
|
||||
const renderedTailCache = new WeakMap<
|
||||
StreamItem[],
|
||||
WeakMap<StreamItem[], Map<string, StreamTurnTiming>>
|
||||
{ historyStart: number; rendered: StreamItem[] }
|
||||
>();
|
||||
|
||||
function getRenderedTail(tail: StreamItem[], historyStart: number | undefined): StreamItem[] {
|
||||
if (!historyStart) {
|
||||
renderedTailCache.delete(tail);
|
||||
return tail;
|
||||
}
|
||||
const cached = renderedTailCache.get(tail);
|
||||
if (cached?.historyStart === historyStart) {
|
||||
return cached.rendered;
|
||||
}
|
||||
const rendered = tail.slice(historyStart);
|
||||
// Revealing older history replaces the previous window, allowing its arrays and
|
||||
// downstream layouts to be collected while the unchanged source tail stays alive.
|
||||
renderedTailCache.set(tail, { historyStart, rendered });
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function getOrderedItems(params: {
|
||||
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
|
||||
source: StreamItem[];
|
||||
@@ -129,32 +147,6 @@ function splitOrderedTail(params: {
|
||||
return split;
|
||||
}
|
||||
|
||||
function getTurnTiming(params: {
|
||||
isTurnActive: boolean;
|
||||
activeTurnStartedAt: Date | null;
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
}): StreamTurnTiming {
|
||||
let cachedByHead = turnTimingCache.get(params.tail);
|
||||
if (!cachedByHead) {
|
||||
cachedByHead = new WeakMap();
|
||||
turnTimingCache.set(params.tail, cachedByHead);
|
||||
}
|
||||
let cachedByActivity = cachedByHead.get(params.head);
|
||||
if (!cachedByActivity) {
|
||||
cachedByActivity = new Map();
|
||||
cachedByHead.set(params.head, cachedByActivity);
|
||||
}
|
||||
const activityKey = `${params.isTurnActive}:${params.activeTurnStartedAt?.getTime() ?? "none"}`;
|
||||
const cached = cachedByActivity.get(activityKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const timing = deriveStreamTurnTiming(params);
|
||||
cachedByActivity.set(activityKey, timing);
|
||||
return timing;
|
||||
}
|
||||
|
||||
export function buildAgentStreamRenderModel(
|
||||
input: BuildAgentStreamRenderModelInput,
|
||||
): AgentStreamRenderModel {
|
||||
@@ -163,7 +155,7 @@ export function buildAgentStreamRenderModel(
|
||||
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||
});
|
||||
const orderingCacheKey = `${input.platform}:${input.isMobileBreakpoint}`;
|
||||
const renderedTail = input.historyStart ? input.tail.slice(input.historyStart) : input.tail;
|
||||
const renderedTail = getRenderedTail(input.tail, input.historyStart);
|
||||
const orderedTail = getOrderedItems({
|
||||
cache: orderedTailCache,
|
||||
source: renderedTail,
|
||||
@@ -189,7 +181,7 @@ export function buildAgentStreamRenderModel(
|
||||
platform: input.platform,
|
||||
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||
});
|
||||
const turnTiming = getTurnTiming({
|
||||
const turnTiming = deriveStreamTurnTiming({
|
||||
isTurnActive: input.isTurnActive,
|
||||
activeTurnStartedAt: input.activeTurnStartedAt,
|
||||
tail: renderedTail,
|
||||
|
||||
@@ -1298,6 +1298,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
<div style={viewportStyle} data-window-content>
|
||||
<div
|
||||
ref={handleScrollContainerRef}
|
||||
// Let clicks focus the feed for browser keyboard scrolling without adding a Tab stop.
|
||||
tabIndex={-1}
|
||||
data-testid="agent-chat-scroll"
|
||||
data-overlay-scrollbar={scrollEnabled && !isMobileBreakpoint ? "true" : undefined}
|
||||
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { bench, describe } from "vitest";
|
||||
import type { StreamItem, ToolCallItem } from "@/types/stream";
|
||||
import {
|
||||
prepareToolCallHistory,
|
||||
projectToolCallDetailLevel,
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
|
||||
import { layoutStream } from "./layout";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
|
||||
// One coalesced delta replaces the stream head. This is the pure work AgentStreamView
|
||||
// does per delta on a long timeline, without React:
|
||||
// npx vitest bench --project unit src/agent-stream/stream-tick.bench.ts
|
||||
|
||||
const TIMELINE_ITEMS = 2_000;
|
||||
const TICKS = 200;
|
||||
|
||||
function timestamp(seed: number): Date {
|
||||
return new Date(1_700_000_000_000 + seed * 1000);
|
||||
}
|
||||
|
||||
function toolCall(id: string, seed: number): ToolCallItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: timestamp(seed),
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: id,
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "shell", command: id },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTimeline(count: number): StreamItem[] {
|
||||
const items: StreamItem[] = [];
|
||||
let seed = 0;
|
||||
while (items.length < count) {
|
||||
const turn = items.length;
|
||||
items.push({
|
||||
kind: "user_message",
|
||||
id: `u${turn}`,
|
||||
text: "prompt",
|
||||
timestamp: timestamp(seed++),
|
||||
});
|
||||
items.push(toolCall(`t${turn}a`, seed++));
|
||||
items.push(toolCall(`t${turn}b`, seed++));
|
||||
items.push({
|
||||
kind: "assistant_message",
|
||||
id: `a${turn}`,
|
||||
text: "answer",
|
||||
timestamp: timestamp(seed++),
|
||||
});
|
||||
}
|
||||
return items.slice(0, count);
|
||||
}
|
||||
|
||||
function buildHeads(base: StreamItem[], ticks: number): StreamItem[][] {
|
||||
let text = "";
|
||||
const heads: StreamItem[][] = [];
|
||||
for (let tick = 0; tick < ticks; tick += 1) {
|
||||
text += "token ";
|
||||
heads.push([
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "live",
|
||||
text,
|
||||
timestamp: timestamp(base.length + tick),
|
||||
},
|
||||
]);
|
||||
}
|
||||
return heads;
|
||||
}
|
||||
|
||||
function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: StreamItem[][] }) {
|
||||
const { tail, historyStart, heads } = input;
|
||||
const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });
|
||||
const prepared = prepareToolCallHistory("overview", tail);
|
||||
const boundaryId = tail[historyStart]?.id ?? null;
|
||||
const activeTurnStartedAt = tail.at(-1)?.timestamp ?? null;
|
||||
for (const head of heads) {
|
||||
const projected = projectToolCallDetailLevel({
|
||||
level: "overview",
|
||||
tail,
|
||||
head,
|
||||
preparedHistory: prepared,
|
||||
isTurnActive: true,
|
||||
});
|
||||
const boundaryIndex = boundaryId
|
||||
? projected.tail.findIndex((item) => item.id === boundaryId)
|
||||
: -1;
|
||||
const model = buildAgentStreamRenderModel({
|
||||
isTurnActive: true,
|
||||
activeTurnStartedAt,
|
||||
tail: projected.tail,
|
||||
head: projected.head,
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
historyStart: boundaryIndex >= 0 ? boundaryIndex : historyStart,
|
||||
});
|
||||
layoutStream({
|
||||
strategy,
|
||||
isTurnActive: true,
|
||||
history: model.history,
|
||||
liveHead: model.segments.liveHead,
|
||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tail = buildTimeline(TIMELINE_ITEMS);
|
||||
const heads = buildHeads(tail, TICKS);
|
||||
const defaultWindowStart = findMountedWindowStart({
|
||||
items: tail,
|
||||
minMountedCount: getMountedRecentStreamItems(),
|
||||
});
|
||||
|
||||
describe(`${TICKS} streamed deltas over a ${TIMELINE_ITEMS}-item timeline`, () => {
|
||||
bench("default mounted window (recent items only)", () => {
|
||||
runTicks({ tail, historyStart: defaultWindowStart, heads });
|
||||
});
|
||||
bench("history fully revealed (scrolled to top)", () => {
|
||||
runTicks({ tail, historyStart: 0, heads });
|
||||
});
|
||||
});
|
||||
@@ -70,10 +70,11 @@ export function useStreamHistoryWindow(input: {
|
||||
boundaryItemId: initialBoundaryItemId,
|
||||
initialized: items.length > 0,
|
||||
}));
|
||||
const boundaryIndex =
|
||||
window.agentId === agentId && window.boundaryItemId !== null
|
||||
? items.findIndex((item) => item.id === window.boundaryItemId)
|
||||
: -1;
|
||||
const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null;
|
||||
const boundaryIndex = useMemo(
|
||||
() => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)),
|
||||
[boundaryItemId, items],
|
||||
);
|
||||
let start = initialStart;
|
||||
if (window.agentId === agentId && window.initialized) {
|
||||
if (window.boundaryItemId === null) {
|
||||
|
||||
@@ -607,16 +607,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
toast?.error(t("agentStream.historyLoadFailed"));
|
||||
}, [t, toast]);
|
||||
// Chat find and the chat outline address messages, and an assistant message is a
|
||||
// group of block rows, so this is a set of message ids and never of row ids.
|
||||
const visibleMessageIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map(
|
||||
getStreamItemMessageId,
|
||||
),
|
||||
),
|
||||
[baseRenderModel.history, baseRenderModel.segments.liveHead],
|
||||
// group of block rows, so these hold message ids and never row ids. Only the head
|
||||
// changes per streamed delta, so the history set is keyed on mounted history alone.
|
||||
const mountedHistoryMessageIds = useMemo(
|
||||
() => new Set(baseRenderModel.history.map(getStreamItemMessageId)),
|
||||
[baseRenderModel.history],
|
||||
);
|
||||
const mountedMessageIds = useMemo(() => {
|
||||
const liveHeadMessageIds = new Set(
|
||||
baseRenderModel.segments.liveHead.map(getStreamItemMessageId),
|
||||
);
|
||||
return {
|
||||
has: (messageId: string) =>
|
||||
mountedHistoryMessageIds.has(messageId) || liveHeadMessageIds.has(messageId),
|
||||
};
|
||||
}, [baseRenderModel.segments.liveHead, mountedHistoryMessageIds]);
|
||||
const chatOutline = useChatOutline({
|
||||
agentId,
|
||||
serverId: resolvedServerId,
|
||||
@@ -626,7 +631,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
enabled: supportsChatOutline && chatOutlineEnabled,
|
||||
viewportRef,
|
||||
onJumpError: handleTimelineHistoryLoadError,
|
||||
visibleMessageIds,
|
||||
mountedHistoryMessageIds,
|
||||
revealLoadedMessage: revealLoadedHistory,
|
||||
});
|
||||
|
||||
@@ -1105,7 +1110,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
items={findItems}
|
||||
viewportRef={viewportRef}
|
||||
revealLoadedMessage={revealLoadedHistory}
|
||||
visibleMessageIds={visibleMessageIds}
|
||||
visibleMessageIds={mountedMessageIds}
|
||||
>
|
||||
<ToolCallSheetProvider>
|
||||
<AssistantSelectionCopySurface style={stylesheet.container}>
|
||||
|
||||
@@ -665,7 +665,7 @@ function MobileGestureWrapper({
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<View collapsable={false} style={layoutStyles.surfaceFill}>
|
||||
<View collapsable={false} style={layoutStyles.surfaceFill} testID="compact-shell">
|
||||
{children}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import {
|
||||
ComposerEditingScope,
|
||||
WorkspaceComposerEditingSessionsProvider,
|
||||
} from "@/composer/editing-session";
|
||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||
import { buildNewWorkspaceDraftKey } from "@/stores/draft-keys";
|
||||
|
||||
/** New workspace drafts share one editing-session home, keyed like a tab by draft. */
|
||||
const NEW_WORKSPACE_SESSIONS_KEY = "new-workspace";
|
||||
|
||||
export default function NewWorkspaceRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
@@ -23,16 +32,28 @@ export default function NewWorkspaceRoute() {
|
||||
draftId ?? null,
|
||||
]);
|
||||
|
||||
// The shell remounts this route when the viewport crosses the compact breakpoint;
|
||||
// the draft's editing session (fullscreen, retained caret) lives above the shell.
|
||||
const draftKey = buildNewWorkspaceDraftKey(draftId);
|
||||
const sessionTabIds = useMemo(() => [draftKey], [draftKey]);
|
||||
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<NewWorkspaceScreen
|
||||
key={screenKey}
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
draftId={draftId}
|
||||
/>
|
||||
<WorkspaceComposerEditingSessionsProvider
|
||||
workspaceKey={NEW_WORKSPACE_SESSIONS_KEY}
|
||||
tabIds={sessionTabIds}
|
||||
>
|
||||
<ComposerEditingScope tabId={draftKey} targetKey={draftKey}>
|
||||
<NewWorkspaceScreen
|
||||
key={screenKey}
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
draftId={draftId}
|
||||
/>
|
||||
</ComposerEditingScope>
|
||||
</WorkspaceComposerEditingSessionsProvider>
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
||||
goose:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">\n <path fill="currentColor" d="M20.9093 19.3861L19.5185 18.2413C18.7624 17.619 18.1189 16.8713 17.6157 16.0313C16.9205 14.8706 15.9599 13.8912 14.8133 13.1735L14.2533 12.8475C14.0614 12.7141 13.9276 12.5062 13.9086 12.2716C13.8963 12.1204 13.9326 11.9852 14.0171 11.8662C14.3087 11.4553 15.896 9.74698 16.1722 9.51845C16.528 9.22442 16.9243 8.97987 17.2921 8.69986C17.3443 8.66 17.3968 8.62035 17.4485 8.57989C17.4503 8.57808 17.4529 8.57668 17.4545 8.57508C17.5725 8.48195 17.6838 8.383 17.7724 8.26563C18.2036 7.76631 18.195 7.3443 18.195 7.3443C18.195 7.3443 18.1954 7.3439 18.1956 7.3437C18.1497 7.23133 17.9847 6.88163 17.6492 6.71759C17.9458 6.71178 18.2805 6.82294 18.4323 6.97156C18.6148 6.68534 18.7328 6.49967 18.9162 6.18762C18.9599 6.11352 18.9831 5.97652 18.8996 5.89981C18.8996 5.89981 18.8992 5.89981 18.8988 5.89981C18.8988 5.89981 18.8988 5.8994 18.8988 5.899C18.8972 5.8974 18.8952 5.8962 18.8936 5.8946C18.892 5.893 18.891 5.89119 18.8892 5.88939C18.8892 5.88939 18.8888 5.88939 18.8884 5.88939C18.8884 5.88939 18.8884 5.88899 18.8884 5.88859C18.885 5.88518 18.8812 5.88258 18.8776 5.87938C18.8754 5.87717 18.8736 5.87457 18.8716 5.87217C18.8692 5.87016 18.8665 5.86836 18.8643 5.86616C18.8609 5.86275 18.8587 5.85855 18.8551 5.85534C18.8551 5.85534 18.8545 5.85514 18.8543 5.85534C18.8543 5.85534 18.8543 5.85494 18.8543 5.85454C18.8527 5.85294 18.8507 5.85174 18.8491 5.85013C18.8475 5.84853 18.8463 5.84653 18.8447 5.84493C18.8447 5.84493 18.8441 5.84473 18.8439 5.84493C18.8439 5.84493 18.8439 5.84453 18.8439 5.84413C18.7672 5.7606 18.6302 5.78384 18.5561 5.8275C18.1503 6.06625 17.7555 6.32322 17.3996 6.54855C17.3996 6.54855 16.9778 6.53973 16.4783 6.97116C16.3607 7.05989 16.2618 7.17125 16.1688 7.28902C16.167 7.29082 16.1654 7.29322 16.164 7.29503C16.1234 7.3465 16.0837 7.39898 16.0441 7.45145C15.7639 7.81939 15.5195 8.21556 15.2255 8.57128C14.9971 8.84768 13.2887 10.4348 12.8777 10.7264C12.7587 10.8109 12.6237 10.8474 12.4723 10.835C12.2379 10.8161 12.0298 10.6821 11.8965 10.4903L11.5704 9.93024C10.8527 8.78318 9.87332 7.82299 8.71264 7.12778C7.87262 6.62466 7.12514 5.98092 6.50264 5.22503L5.35778 3.83421C5.3013 3.76571 5.19314 3.77693 5.15268 3.85585C5.02249 4.10941 4.77393 4.64479 4.58346 5.36483C4.57885 5.38186 4.58286 5.39988 4.59407 5.4135C4.83082 5.69952 5.37901 6.32983 6.03196 6.863C6.07742 6.90005 6.04017 6.97336 5.98369 6.95774C5.42047 6.80432 4.87288 6.55796 4.46308 6.34805C4.42964 6.33103 4.38918 6.35226 4.38437 6.38951C4.32068 6.89985 4.30425 7.46027 4.37155 8.05112C4.37355 8.07035 4.38577 8.08697 4.4036 8.09479C4.87088 8.29808 5.61816 8.59311 6.40269 8.78078C6.45958 8.7944 6.45777 8.87632 6.40029 8.88733C5.78941 9.0023 5.14968 9.02794 4.62973 9.02113C4.59327 9.02073 4.56643 9.05518 4.57625 9.09023C4.6806 9.45896 4.822 9.8339 5.00847 10.2115C5.08559 10.3811 5.16951 10.5475 5.25944 10.7104C5.27486 10.7382 5.3047 10.7548 5.33655 10.7534C5.76577 10.7324 6.28452 10.6871 6.80608 10.595C6.89501 10.5794 6.94268 10.6964 6.86757 10.7466C6.51345 10.9834 6.13571 11.1873 5.7844 11.3551C5.73733 11.3777 5.72211 11.4378 5.75315 11.4797C5.96186 11.7625 6.19139 12.0301 6.44075 12.2794C6.44075 12.2794 7.66853 13.5441 7.70198 13.6432C8.41841 12.9096 9.59612 12.0964 10.8966 11.3864C9.15488 12.8036 8.18387 13.8499 7.69517 14.4444L7.35447 14.9225C7.17742 15.1708 7.02379 15.4346 6.89541 15.7112C6.46579 16.6356 5.75756 18.5051 5.75756 18.5051C5.70328 18.6515 5.74754 18.7959 5.84168 18.89C5.84388 18.8922 5.84609 18.8944 5.84849 18.8964C5.85069 18.8986 5.8527 18.901 5.8549 18.9032C5.94924 18.9976 6.09345 19.0416 6.23986 18.9874C6.23986 18.9874 8.10897 18.2791 9.03371 17.8495C9.31031 17.7211 9.57429 17.5673 9.82245 17.3905L10.349 17.0153C10.6278 16.8166 11.0096 16.8483 11.2517 17.0904L12.4655 18.3042C12.7148 18.5535 12.9824 18.7831 13.2652 18.9918C13.3073 19.0226 13.3672 19.0076 13.3898 18.9605C13.5579 18.6094 13.7618 18.2313 13.9983 17.8774C14.0486 17.8022 14.1657 17.8501 14.1499 17.9388C14.0576 18.4606 14.0127 18.9794 13.9915 19.4084C13.9899 19.44 14.0067 19.4701 14.0345 19.4855C14.1972 19.5756 14.3636 19.6595 14.5335 19.7364C14.911 19.9229 15.2862 20.0645 15.6547 20.1687C15.6897 20.1785 15.7242 20.1516 15.7238 20.1152C15.7168 19.595 15.7424 18.9553 15.8576 18.3446C15.8684 18.2869 15.9503 18.2851 15.9641 18.3422C16.1516 19.127 16.4466 19.8742 16.6501 20.3413C16.6579 20.3591 16.6744 20.3712 16.6938 20.3734C17.2847 20.4407 17.8451 20.4242 18.3554 20.3606C18.3929 20.3559 18.4141 20.3155 18.3969 20.2818C18.187 19.872 17.9406 19.3241 17.7872 18.7612C17.7718 18.7046 17.8449 18.6675 17.8819 18.713C18.4151 19.3659 19.0454 19.9141 19.3314 20.1508C19.345 20.1621 19.3633 20.1659 19.3801 20.1615C20.1003 19.9712 20.6357 19.7226 20.8891 19.5922C20.968 19.5518 20.9792 19.4436 20.9107 19.3871L20.9093 19.3861Z"/>\n</svg>\n',
|
||||
grok: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1024 1024" fill="none">\n <path fill="currentColor" d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916"/>\n <path fill="currentColor" d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339"/>\n</svg>\n',
|
||||
hoplite:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="1.394 1.679 8.494 7.828" fill="currentColor"><path d="M8.237 7.813C8.276 7.789 8.327 7.796 8.358 7.83L9.741 9.346C9.797 9.407 9.754 9.506 9.671 9.507H8.977C8.924 9.507 8.881 9.463 8.881 9.41V9.125C8.881 9.089 8.861 9.057 8.83 9.04L7.589 8.379C7.524 8.344 7.521 8.251 7.584 8.212L8.237 7.813ZM3.944 1.679C4.502 1.679 5.052 1.82 5.54 2.09L5.962 2.323C6.024 2.358 6.029 2.446 5.97 2.486L5.091 3.09C5.065 3.108 5.05 3.137 5.05 3.169V3.495C5.05 3.529 5.032 3.561 5.003 3.578L4.68 3.769C4.594 3.82 4.498 3.722 4.549 3.637L4.592 3.566C4.63 3.502 4.584 3.421 4.51 3.421H4.028C3.925 3.421 3.894 3.562 3.989 3.604L4.377 3.775C4.448 3.806 4.455 3.904 4.389 3.944L3.77 4.323C3.755 4.332 3.737 4.337 3.72 4.337H3.361C3.309 4.337 3.268 4.379 3.268 4.431C3.268 4.484 3.309 4.526 3.361 4.526H3.779C3.797 4.526 3.814 4.521 3.829 4.513L7.599 2.272C8.135 1.954 8.815 2.008 9.294 2.407C9.307 2.418 9.314 2.434 9.314 2.451V2.729C9.315 2.812 9.413 2.856 9.475 2.8L9.728 2.57C9.789 2.514 9.888 2.558 9.888 2.641V5.812C9.888 5.865 9.845 5.908 9.792 5.908H9.359C9.331 5.908 9.303 5.895 9.285 5.873L8.374 4.773C8.337 4.728 8.268 4.728 8.229 4.771L7.468 5.625C7.429 5.669 7.438 5.737 7.487 5.769L9.038 6.776C9.098 6.815 9.096 6.903 9.035 6.939L6.618 8.363C6.556 8.4 6.478 8.358 6.474 8.286L6.439 7.633C6.437 7.578 6.48 7.532 6.535 7.532H6.925C6.948 7.532 6.971 7.523 6.988 7.508L7.211 7.31C7.276 7.252 7.236 7.144 7.148 7.142L5.991 7.118H1.49C1.437 7.118 1.394 7.075 1.394 7.022V3.308C1.394 3.255 1.437 3.212 1.49 3.212H2.544C2.597 3.212 2.64 3.169 2.64 3.116V2.969C2.64 2.916 2.597 2.873 2.544 2.873H1.461C1.366 2.873 1.329 2.75 1.408 2.697L2.134 2.221C2.672 1.867 3.301 1.679 3.944 1.679Z"/></svg>\n',
|
||||
junie:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40" fill="none">\n <path d="M25 15H35V16.75C35 29 30.5001 35 16.5001 35H15V25H16.5001C22.6251 25 25 22.875 25 16.75V15Z" fill="currentColor"/>\n <rect x="5" y="15" width="10" height="10" fill="currentColor"/>\n <rect x="15" y="5" width="10" height="10" fill="currentColor"/>\n</svg>\n',
|
||||
kilo: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40">\n <path fill="currentColor" d="M27.9998324,34.6666921h6.6666279v5.3333023h-8.3809037l-3.6190266-3.6190266v-8.3809037h5.3333023v6.6666279ZM39.9997627,26.2857884l-3.6190266-3.6190266h-8.3809037v5.3333023h6.6666279v6.6666279h5.3333023v-8.3809037ZM17.3332326,22.6667578h-5.3333023v5.3333023h5.3333023v-5.3333023ZM0,36.3809638l3.6190266,3.6190266h13.714206v-5.3333023H5.3333023v-11.9999302H0v13.714206ZM33.9600959,11.9999247V3.619021L30.3410693-.0000056h-7.6745392v5.3333023h5.9602634v6.6666279h-5.9602634v5.3333023h17.3332326v-5.3333023h-6.0396668ZM5.3333023,0H0v17.3332326h5.3333023v-5.9999651h6.6666279v5.9999651h5.3333023v-5.9999651l-5.3333023-5.3333023h-6.6666279V0ZM17.3332326,0h-5.3333023v5.9999651h5.3333023V0Z"/>\n</svg>\n',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="1.394 1.679 8.494 7.828" fill="currentColor"><path d="M8.237 7.813C8.276 7.789 8.327 7.796 8.358 7.83L9.741 9.346C9.797 9.407 9.754 9.506 9.671 9.507H8.977C8.924 9.507 8.881 9.463 8.881 9.41V9.125C8.881 9.089 8.861 9.057 8.83 9.04L7.589 8.379C7.524 8.344 7.521 8.251 7.584 8.212L8.237 7.813ZM3.944 1.679C4.502 1.679 5.052 1.82 5.54 2.09L5.962 2.323C6.024 2.358 6.029 2.446 5.97 2.486L5.091 3.09C5.065 3.108 5.05 3.137 5.05 3.169V3.495C5.05 3.529 5.032 3.561 5.003 3.578L4.68 3.769C4.594 3.82 4.498 3.722 4.549 3.637L4.592 3.566C4.63 3.502 4.584 3.421 4.51 3.421H4.028C3.925 3.421 3.894 3.562 3.989 3.604L4.377 3.775C4.448 3.806 4.455 3.904 4.389 3.944L3.77 4.323C3.755 4.332 3.737 4.337 3.72 4.337H3.361C3.309 4.337 3.268 4.379 3.268 4.431C3.268 4.484 3.309 4.526 3.361 4.526H3.779C3.797 4.526 3.814 4.521 3.829 4.513L7.599 2.272C8.135 1.954 8.815 2.008 9.294 2.407C9.307 2.418 9.314 2.434 9.314 2.451V2.729C9.315 2.812 9.413 2.856 9.475 2.8L9.728 2.57C9.789 2.514 9.888 2.558 9.888 2.641V5.812C9.888 5.865 9.845 5.908 9.792 5.908H9.359C9.331 5.908 9.303 5.895 9.285 5.873L8.374 4.773C8.337 4.728 8.268 4.728 8.229 4.771L7.468 5.625C7.429 5.669 7.438 5.737 7.487 5.769L9.038 6.776C9.098 6.815 9.096 6.903 9.035 6.939L6.618 8.363C6.556 8.4 6.478 8.358 6.474 8.286L6.439 7.633C6.437 7.578 6.48 7.532 6.535 7.532H6.925C6.948 7.532 6.971 7.523 6.988 7.508L7.211 7.31C7.276 7.252 7.236 7.144 7.148 7.142L5.991 7.118H1.49C1.437 7.118 1.394 7.075 1.394 7.022V3.308C1.394 3.255 1.437 3.212 1.49 3.212H2.544C2.597 3.212 2.64 3.169 2.64 3.116V2.969C2.64 2.916 2.597 2.873 2.544 2.873H1.461C1.366 2.873 1.329 2.75 1.408 2.697L2.134 2.221C2.672 1.867 3.301 1.679 3.944 1.679Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Keyboard, Pressable, Text, View } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
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,
|
||||
@@ -223,7 +223,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
const WEB_EXIT_DURATION_MS = 160;
|
||||
|
||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */
|
||||
export function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const combinedStyle = useMemo(
|
||||
() => [
|
||||
@@ -239,6 +240,39 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return <Animated.View pointerEvents="none" style={combinedStyle} />;
|
||||
}
|
||||
|
||||
const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({
|
||||
indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] },
|
||||
}));
|
||||
|
||||
const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 };
|
||||
|
||||
/**
|
||||
* The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children
|
||||
* join the handle, so a header passed here drags the sheet like the indicator does. That is how a
|
||||
* sheet whose content scrolls on its own keeps its header draggable without content panning.
|
||||
*/
|
||||
export function SheetHandle({
|
||||
children,
|
||||
// Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator.
|
||||
indicatorStyle: _indicatorStyle,
|
||||
...props
|
||||
}: ComponentProps<typeof BottomSheetHandle>) {
|
||||
if (!children) return <ThemedBottomSheetHandle {...props} />;
|
||||
// The children carry their own semantics; the handle's slider role would swallow them on web.
|
||||
return (
|
||||
<ThemedBottomSheetHandle
|
||||
{...props}
|
||||
style={HANDLE_WITH_CONTENT_STYLE}
|
||||
accessible={false}
|
||||
accessibilityRole={null}
|
||||
accessibilityLabel={null}
|
||||
accessibilityHint={null}
|
||||
>
|
||||
{children}
|
||||
</ThemedBottomSheetHandle>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet body, indented to the sheet's content inset.
|
||||
*
|
||||
@@ -253,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,
|
||||
@@ -309,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 ? (
|
||||
@@ -449,7 +506,6 @@ export function AdaptiveModalSheet({
|
||||
bodyStyle,
|
||||
contextBridge = null,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -481,10 +537,6 @@ export function AdaptiveModalSheet({
|
||||
) : null,
|
||||
[footer, footerClearanceStyle, footerContainerStyle],
|
||||
);
|
||||
const handleIndicatorStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isWeb && visible) {
|
||||
// A newly opened sheet owns input. A keyboard belonging to the sheet below
|
||||
@@ -600,7 +652,7 @@ export function AdaptiveModalSheet({
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
handleComponent={SheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import {
|
||||
Modal,
|
||||
@@ -71,6 +72,7 @@ import {
|
||||
type ProjectPickerOption,
|
||||
} from "@/components/project-picker-options";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useRevealActiveItem } from "@/components/ui/scroll-reveal";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
@@ -252,7 +254,17 @@ function pathTestId(path: string): string {
|
||||
return `add-project-flow-path-${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
function FlowRow({ option, active }: { option: FlowRowOption; active: boolean }) {
|
||||
function FlowRow({
|
||||
option,
|
||||
active,
|
||||
activeItemRef,
|
||||
onRowLayout,
|
||||
}: {
|
||||
option: FlowRowOption;
|
||||
active: boolean;
|
||||
activeItemRef: RefObject<View | null>;
|
||||
onRowLayout: () => void;
|
||||
}) {
|
||||
const accessibilityState = useMemo(
|
||||
() => ({ disabled: option.disabled === true, selected: active }),
|
||||
[active, option.disabled],
|
||||
@@ -270,6 +282,8 @@ function FlowRow({ option, active }: { option: FlowRowOption; active: boolean })
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={option.disabled}
|
||||
ref={active ? activeItemRef : undefined}
|
||||
onLayout={active ? onRowLayout : undefined}
|
||||
onPress={option.select}
|
||||
style={rowStyle}
|
||||
testID={option.testID}
|
||||
@@ -384,6 +398,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
);
|
||||
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const resultsRef = useRef<ScrollView>(null);
|
||||
const submissionInFlightRef = useRef(false);
|
||||
const browseInFlightRef = useRef(false);
|
||||
const query = page.kind === "new-directory-name" || page.kind === "method" ? "" : page.query;
|
||||
@@ -715,6 +730,13 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
]);
|
||||
|
||||
const activeIndex = rows.length === 0 ? 0 : Math.min(page.activeIndex, rows.length - 1);
|
||||
const rowListKey = useMemo(() => rows.map((row) => row.id).join("\u0000"), [rows]);
|
||||
const { activeItemRef, contentRef, onItemLayout, onViewportLayout, onScroll } =
|
||||
useRevealActiveItem({
|
||||
activeIndex,
|
||||
listKey: rowListKey,
|
||||
scrollRef: resultsRef,
|
||||
});
|
||||
const createDirectory = useCallback(async () => {
|
||||
if (page.kind !== "new-directory-name" || !client) return;
|
||||
const name = page.name.trim();
|
||||
@@ -905,53 +927,64 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
) : null}
|
||||
</View>
|
||||
<ScrollView
|
||||
ref={resultsRef}
|
||||
style={styles.results}
|
||||
contentContainerStyle={styles.resultsContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
onLayout={onViewportLayout}
|
||||
onScroll={onScroll}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="add-project-flow-results"
|
||||
>
|
||||
{preview ? (
|
||||
<Text style={styles.preview} testID="add-project-flow-path-preview">
|
||||
{shortenPath(preview)}
|
||||
</Text>
|
||||
) : null}
|
||||
{isSubmitting ? (
|
||||
<Text style={styles.stateText} testID="add-project-flow-progress">
|
||||
{progressText(page)}
|
||||
</Text>
|
||||
) : null}
|
||||
{!isSubmitting && page.error ? (
|
||||
<Text style={styles.errorText} testID="add-project-flow-error">
|
||||
{page.error}
|
||||
</Text>
|
||||
) : null}
|
||||
{!isSubmitting && queryError ? (
|
||||
<Text style={styles.errorText} testID="add-project-flow-query-error">
|
||||
{queryError}
|
||||
</Text>
|
||||
) : null}
|
||||
{!isSubmitting && loading ? (
|
||||
<Text style={styles.stateText} testID="add-project-flow-loading">
|
||||
Loading...
|
||||
</Text>
|
||||
) : null}
|
||||
{!isSubmitting &&
|
||||
(!loading || page.kind === "github-search") &&
|
||||
(!queryError || page.kind === "github-search")
|
||||
? rows.map((option, index) => (
|
||||
<FlowRow key={option.id} option={option} active={index === activeIndex} />
|
||||
))
|
||||
: null}
|
||||
{!isSubmitting &&
|
||||
!loading &&
|
||||
!queryError &&
|
||||
rows.length === 0 &&
|
||||
page.kind !== "new-directory-name" ? (
|
||||
<Text style={styles.stateText} testID="add-project-flow-empty">
|
||||
{emptyText(page, host ?? null)}
|
||||
</Text>
|
||||
) : null}
|
||||
<View ref={contentRef} collapsable={false} style={styles.resultsContent}>
|
||||
{preview ? (
|
||||
<Text style={styles.preview} testID="add-project-flow-path-preview">
|
||||
{shortenPath(preview)}
|
||||
</Text>
|
||||
) : null}
|
||||
{isSubmitting ? (
|
||||
<Text style={styles.stateText} testID="add-project-flow-progress">
|
||||
{progressText(page)}
|
||||
</Text>
|
||||
) : null}
|
||||
{!isSubmitting && page.error ? (
|
||||
<Text style={styles.errorText} testID="add-project-flow-error">
|
||||
{page.error}
|
||||
</Text>
|
||||
) : null}
|
||||
{!isSubmitting && queryError ? (
|
||||
<Text style={styles.errorText} testID="add-project-flow-query-error">
|
||||
{queryError}
|
||||
</Text>
|
||||
) : null}
|
||||
{!isSubmitting && loading ? (
|
||||
<Text style={styles.stateText} testID="add-project-flow-loading">
|
||||
Loading...
|
||||
</Text>
|
||||
) : null}
|
||||
{!isSubmitting &&
|
||||
(!loading || page.kind === "github-search") &&
|
||||
(!queryError || page.kind === "github-search")
|
||||
? rows.map((option, index) => (
|
||||
<FlowRow
|
||||
key={option.id}
|
||||
option={option}
|
||||
activeItemRef={activeItemRef}
|
||||
active={index === activeIndex}
|
||||
onRowLayout={onItemLayout}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{!isSubmitting &&
|
||||
!loading &&
|
||||
!queryError &&
|
||||
rows.length === 0 &&
|
||||
page.kind !== "new-directory-name" ? (
|
||||
<Text style={styles.stateText} testID="add-project-flow-empty">
|
||||
{emptyText(page, host ?? null)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<View style={styles.footer} testID="add-project-flow-footer">
|
||||
<FlowHint keys={NAVIGATION_HINT_KEYS} action="Navigate" />
|
||||
|
||||
@@ -35,6 +35,7 @@ import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -805,21 +806,18 @@ function DesktopSidebar({
|
||||
|
||||
function WorkspacesSectionHeader() {
|
||||
return (
|
||||
<View style={styles.workspacesSectionHeader}>
|
||||
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
|
||||
<View style={styles.workspacesSectionActions}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<SidebarDisplayPreferencesMenu />
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<IconTooltipContent label="Display preferences" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
</View>
|
||||
<SidebarSectionHeader title="Workspaces">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<SidebarDisplayPreferencesMenu />
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<IconTooltipContent label="Display preferences" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</SidebarSectionHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -850,29 +848,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarHeaderGroupBelowChrome: {
|
||||
paddingTop: 0,
|
||||
},
|
||||
workspacesSectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title
|
||||
// lands at spacing[2] left to align with project icons. Settings2's painted path stops
|
||||
// inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail.
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: 4,
|
||||
paddingTop: theme.spacing[1],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
workspacesSectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
workspacesSectionActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -3,6 +3,71 @@ import { describe, expect, it } from "vitest";
|
||||
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
|
||||
|
||||
describe("splitHtmlishMarkdown", () => {
|
||||
it("renders heading text beside a linked picture without literal heading tags", () => {
|
||||
const source =
|
||||
'<h2><a href="https://example.com/r"><picture><source srcset="https://example.com/d.svg"><img alt="Retrigger" src="https://example.com/l.svg"></picture></a>Confidence Score: 5/5</h2>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Retrigger",
|
||||
src: "https://example.com/l.svg",
|
||||
href: "https://example.com/r",
|
||||
flowsWithText: true,
|
||||
},
|
||||
{ kind: "markdown", text: "Confidence Score: 5/5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["h1", "h2", "h3", "h4", "h5", "h6"])(
|
||||
"keeps a mixed %s heading together after preceding markdown",
|
||||
(heading) => {
|
||||
const source = `Intro\n\n<${heading}><img alt="Icon" src="https://example.com/icon.svg">Caption</${heading}>\n\nOutro`;
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{ kind: "markdown", text: "Intro\n\n" },
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Icon",
|
||||
src: "https://example.com/icon.svg",
|
||||
flowsWithText: true,
|
||||
},
|
||||
{ kind: "markdown", text: "Caption\n\nOutro" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves formatted text before and after images in a details heading", () => {
|
||||
const source =
|
||||
'<details><summary>More</summary><h2><strong>Before</strong><img alt="Icon" src="https://example.com/icon.svg"><em>After</em></h2></details>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "details",
|
||||
summary: "More",
|
||||
body: "**Before***After*",
|
||||
bodyParts: [
|
||||
{ kind: "markdown", text: "**Before**" },
|
||||
{ kind: "inlineImage", alt: "Icon", src: "https://example.com/icon.svg" },
|
||||
{ kind: "markdown", text: "*After*" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps mixed HTML headings literal in code examples", () => {
|
||||
const heading = '<h2><img src="https://example.com/icon.svg">Caption</h2>';
|
||||
const source = `Use \`${heading}\`\n\n\`\`\`html\n${heading}\n\`\`\``;
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("preserves unknown wrappers around text-only headings", () => {
|
||||
expect(splitHtmlishMarkdown("<section><h2>Heading</h2></section>")).toEqual([
|
||||
{ kind: "markdown", text: "<section>Heading</section>" },
|
||||
]);
|
||||
});
|
||||
|
||||
const inlineImageBody = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
|
||||
"",
|
||||
|
||||
@@ -125,9 +125,20 @@ function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
|
||||
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
|
||||
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
|
||||
if (isHeadingTag(token)) {
|
||||
const closeIndex = findMixedHeadingClose(tokens, cursor);
|
||||
if (closeIndex !== null) {
|
||||
// Headings render as their contents. Split those contents together so an
|
||||
// embedded image cannot strand the opening heading tag in a text slice.
|
||||
for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) {
|
||||
appendDisplayPart(parts, part);
|
||||
}
|
||||
cursor = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length;
|
||||
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
|
||||
cursor = end;
|
||||
}
|
||||
@@ -259,15 +270,44 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
|
||||
return false;
|
||||
}
|
||||
|
||||
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (parseInlineImageAt(tokens, index)) {
|
||||
if (
|
||||
isOpenTag(tokens[index], "details") ||
|
||||
findMixedHeadingClose(tokens, index) !== null ||
|
||||
parseInlineImageAt(tokens, index)
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null {
|
||||
const token = tokens[start];
|
||||
if (!isHeadingTag(token)) {
|
||||
return null;
|
||||
}
|
||||
const closeIndex = findMatchingClose(tokens, start, token.name);
|
||||
if (closeIndex === null) {
|
||||
return null;
|
||||
}
|
||||
for (let index = start + 1; index < closeIndex; index += 1) {
|
||||
if (parseInlineImageAt(tokens, index)) {
|
||||
return closeIndex;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void {
|
||||
if (part.kind === "markdown") {
|
||||
appendMarkdownPart(parts, part.text);
|
||||
} else {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
|
||||
if (!text) {
|
||||
return;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Text, View, type LayoutChangeEvent } from "react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
|
||||
import { PackagePlus } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { SearchField } from "@/components/ui/search-field";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { rankProviderCatalog } from "@/components/provider-catalog-search";
|
||||
import {
|
||||
useAcpProviderCatalog,
|
||||
type AcpProviderCatalogItem,
|
||||
} from "@/hooks/use-acp-provider-catalog";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import { SPACING, type Theme } from "@/styles/theme";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
interface ProviderCatalogListProps {
|
||||
serverId: string;
|
||||
@@ -21,52 +23,43 @@ interface ProviderCatalogListProps {
|
||||
onInstall: (entry: AcpProviderCatalogItem) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const SEARCH_ICON_SIZE = 16;
|
||||
const PROVIDER_FALLBACK_ICON_SIZE = 20;
|
||||
const PROVIDER_REMOTE_ICON_SIZE = 24;
|
||||
const TILE_GAP = SPACING[3];
|
||||
/** Narrowest tile that keeps the icon, type badge, install link, and Add on one row. */
|
||||
const MIN_TILE_WIDTH = 300;
|
||||
const MAX_COLUMNS = 4;
|
||||
|
||||
const ThemedPackagePlus = withUnistyles(PackagePlus);
|
||||
const ThemedSvgXml = withUnistyles(SvgXml);
|
||||
const ThemedSearch = withUnistyles(Search);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
return [entry.title, entry.id, entry.description].some((value) =>
|
||||
value.toLowerCase().includes(normalized),
|
||||
);
|
||||
function columnCountForWidth(width: number): number {
|
||||
const fits = Math.floor((width + TILE_GAP) / (MIN_TILE_WIDTH + TILE_GAP));
|
||||
return Math.min(MAX_COLUMNS, Math.max(1, fits));
|
||||
}
|
||||
|
||||
interface CatalogRowProps {
|
||||
interface CatalogTileProps {
|
||||
entry: AcpProviderCatalogItem;
|
||||
/** `undefined` until the grid is measured, or in a single column; the tile fills the row. */
|
||||
width: number | undefined;
|
||||
installing: boolean;
|
||||
onInstall: (entry: AcpProviderCatalogItem) => void;
|
||||
}
|
||||
|
||||
function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
|
||||
function CatalogTile({ entry, width, installing, onInstall }: CatalogTileProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionLabel = installing
|
||||
? t("providerCatalog.actions.adding")
|
||||
: t("providerCatalog.actions.add");
|
||||
const handleInstall = useCallback(() => {
|
||||
onInstall(entry);
|
||||
}, [entry, onInstall]);
|
||||
|
||||
const handleOpenInstallLink = useCallback(() => {
|
||||
void openExternalUrl(entry.installLink);
|
||||
}, [entry.installLink]);
|
||||
const tileStyle = useMemo(
|
||||
() => [settingsStyles.card, styles.tile, width === undefined ? styles.tileFull : { width }],
|
||||
[width],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={tileStyle} testID={`provider-catalog-tile-${entry.id}`}>
|
||||
<View style={styles.iconFrame}>
|
||||
{entry.iconSvg ? (
|
||||
<ThemedSvgXml
|
||||
@@ -79,42 +72,33 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
|
||||
<ThemedPackagePlus size={PROVIDER_FALLBACK_ICON_SIZE} uniProps={foregroundColorMapping} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.textColumn}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{entry.title}
|
||||
</Text>
|
||||
<Text style={styles.version} numberOfLines={1}>
|
||||
{entry.version}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.description} numberOfLines={1}>
|
||||
{entry.description || entry.id}
|
||||
<View style={styles.details}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{entry.title}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
|
||||
provider: entry.title,
|
||||
})}
|
||||
onPress={handleOpenInstallLink}
|
||||
style={styles.installLink}
|
||||
>
|
||||
<Text style={styles.installLinkText} numberOfLines={1}>
|
||||
{t("providerCatalog.actions.installInstructions")}
|
||||
</Text>
|
||||
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
|
||||
</Pressable>
|
||||
<View style={styles.metaRow}>
|
||||
{/* The catalog is the ACP registry, so every entry it offers is an ACP provider. */}
|
||||
<StatusBadge label={t("providerCatalog.types.acp")} size="xs" />
|
||||
<ExternalLink
|
||||
href={entry.installLink}
|
||||
label={t("providerCatalog.actions.installInstructions")}
|
||||
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
|
||||
provider: entry.title,
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={installing}
|
||||
loading={installing}
|
||||
onPress={handleInstall}
|
||||
style={styles.actionButton}
|
||||
accessibilityLabel={t("providerCatalog.actions.addProvider", { provider: entry.title })}
|
||||
style={styles.addButton}
|
||||
testID={`install-provider-${entry.id}`}
|
||||
>
|
||||
{actionLabel}
|
||||
{installing ? t("providerCatalog.actions.adding") : t("providerCatalog.actions.add")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -129,6 +113,7 @@ export function ProviderCatalogList({
|
||||
const { entries: catalogEntries } = useAcpProviderCatalog();
|
||||
const { entries: providerEntries } = useProvidersSnapshot(serverId);
|
||||
const [search, setSearch] = useState("");
|
||||
const [gridWidth, setGridWidth] = useState<number | null>(null);
|
||||
|
||||
const installedIds = useMemo(
|
||||
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
|
||||
@@ -137,41 +122,48 @@ export function ProviderCatalogList({
|
||||
|
||||
const availableEntries = useMemo(
|
||||
() =>
|
||||
catalogEntries
|
||||
.filter((entry) => !installedIds.has(entry.id))
|
||||
.filter((entry) => matchesSearch(entry, search)),
|
||||
rankProviderCatalog(
|
||||
catalogEntries.filter((entry) => !installedIds.has(entry.id)),
|
||||
search,
|
||||
),
|
||||
[catalogEntries, installedIds, search],
|
||||
);
|
||||
|
||||
const handleGridLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setGridWidth(event.nativeEvent.layout.width);
|
||||
}, []);
|
||||
|
||||
const tileWidth = useMemo(() => {
|
||||
if (gridWidth === null) return undefined;
|
||||
const columns = columnCountForWidth(gridWidth);
|
||||
if (columns === 1) return undefined;
|
||||
return Math.floor((gridWidth - TILE_GAP * (columns - 1)) / columns);
|
||||
}, [gridWidth]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.searchField}>
|
||||
<View style={styles.searchIcon}>
|
||||
<ThemedSearch size={SEARCH_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
<ThemedTextInput
|
||||
testID="provider-catalog-search"
|
||||
initialValue={search}
|
||||
<View style={styles.searchRail}>
|
||||
<SearchField
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
accessibilityLabel={t("providerCatalog.search")}
|
||||
placeholder={t("providerCatalog.search")}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[styles.searchInput, isWeb && { outlineStyle: "none" }]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearAccessibilityLabel={t("providerCatalog.clearSearch")}
|
||||
testID="provider-catalog-search"
|
||||
clearTestID="provider-catalog-search-clear"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{availableEntries.length === 0 ? (
|
||||
<View style={styles.stateBox}>
|
||||
<View style={[settingsStyles.card, styles.stateBox]}>
|
||||
<Text style={styles.stateText}>{t("providerCatalog.noProviders")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.list}>
|
||||
<View style={styles.grid} onLayout={handleGridLayout}>
|
||||
{availableEntries.map((entry) => (
|
||||
<CatalogRow
|
||||
<CatalogTile
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
width={tileWidth}
|
||||
installing={installingProviderId === entry.id}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
@@ -183,101 +175,56 @@ export function ProviderCatalogList({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
searchField: {
|
||||
searchRail: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
searchIcon: {
|
||||
width: 18,
|
||||
alignItems: "center",
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: TILE_GAP,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
list: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
row: {
|
||||
tile: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
tileFull: {
|
||||
width: "100%",
|
||||
},
|
||||
details: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
iconFrame: {
|
||||
flexShrink: 0,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
addButton: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
textColumn: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
name: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
flexShrink: 1,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
version: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
flexShrink: 0,
|
||||
},
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
installLink: {
|
||||
alignSelf: "flex-start",
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
maxWidth: "100%",
|
||||
},
|
||||
installLinkText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actionButton: {
|
||||
width: 92,
|
||||
flexShrink: 0,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
stateBox: {
|
||||
minHeight: 96,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
stateText: {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rankProviderCatalog } from "./provider-catalog-search";
|
||||
|
||||
const entries = [
|
||||
{ id: "kiro-cli", title: "Kiro CLI" },
|
||||
{ id: "goose", title: "goose" },
|
||||
{ id: "kimi", title: "Kimi Code CLI" },
|
||||
{ id: "gemini", title: "Gemini CLI" },
|
||||
{ id: "qwen-code", title: "Qwen Code" },
|
||||
{ id: "codex-acp", title: "Codex" },
|
||||
];
|
||||
|
||||
function titles(list: { title: string }[]): string[] {
|
||||
return list.map((entry) => entry.title);
|
||||
}
|
||||
|
||||
describe("rankProviderCatalog", () => {
|
||||
it("lists every provider alphabetically by name without a query", () => {
|
||||
expect(titles(rankProviderCatalog(entries, " "))).toEqual([
|
||||
"Codex",
|
||||
"Gemini CLI",
|
||||
"goose",
|
||||
"Kimi Code CLI",
|
||||
"Kiro CLI",
|
||||
"Qwen Code",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ranks the strongest match first and drops providers that do not match", () => {
|
||||
expect(titles(rankProviderCatalog(entries, "cod"))).toEqual([
|
||||
"Codex",
|
||||
"Kimi Code CLI",
|
||||
"Qwen Code",
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches in-word subsequences and multiple tokens like the other pickers", () => {
|
||||
expect(titles(rankProviderCatalog(entries, "gmni"))).toEqual(["Gemini CLI"]);
|
||||
expect(titles(rankProviderCatalog(entries, "qw cod"))).toEqual(["Qwen Code"]);
|
||||
});
|
||||
|
||||
it("matches the provider id", () => {
|
||||
expect(titles(rankProviderCatalog(entries, "kiro-cli"))).toEqual(["Kiro CLI"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
compareMatchScores,
|
||||
scoreTextFields,
|
||||
type MatchScore,
|
||||
} from "@getpaseo/protocol/search/text-match";
|
||||
|
||||
export interface SearchableCatalogEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function compareTitles(a: SearchableCatalogEntry, b: SearchableCatalogEntry): number {
|
||||
return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders the Add provider catalog. Without a query the list is alphabetical by
|
||||
* name. With a query it uses the pickers' ranked matcher over the fields the
|
||||
* tile shows (name and id), best match first, ties alphabetical.
|
||||
*/
|
||||
export function rankProviderCatalog<T extends SearchableCatalogEntry>(
|
||||
entries: readonly T[],
|
||||
query: string,
|
||||
): T[] {
|
||||
if (!query.trim()) return [...entries].sort(compareTitles);
|
||||
|
||||
const scored: { entry: T; score: MatchScore }[] = [];
|
||||
for (const entry of entries) {
|
||||
const score = scoreTextFields(query, [entry.title, entry.id]);
|
||||
if (score) scored.push({ entry, score });
|
||||
}
|
||||
scored.sort((a, b) => compareMatchScores(a.score, b.score) || compareTitles(a.entry, b.entry));
|
||||
return scored.map(({ entry }) => entry);
|
||||
}
|
||||
@@ -95,11 +95,11 @@ function CustomModelRow({
|
||||
|
||||
return (
|
||||
<View style={sheetStyles.modelRow}>
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
@@ -762,6 +762,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
iconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -807,6 +808,10 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
customModelText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
modelRowFiller: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -147,4 +147,29 @@ describe("question form card core", () => {
|
||||
Response: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps option previews so options can be compared", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which layout?",
|
||||
header: "Layout",
|
||||
options: [
|
||||
{
|
||||
label: "Tabs",
|
||||
description: "One tab per section",
|
||||
preview: "<Tabs>\n <Tab />\n</Tabs>",
|
||||
},
|
||||
{ label: "Single page" },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(questions?.[0]?.options).toEqual([
|
||||
{ label: "Tabs", description: "One tab per section", preview: "<Tabs>\n <Tab />\n</Tabs>" },
|
||||
{ label: "Single page", description: undefined, preview: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface QuestionFormQuestion {
|
||||
@@ -46,6 +47,7 @@ export function parseQuestionFormQuestions(input: unknown): QuestionFormQuestion
|
||||
options.push({
|
||||
label: o.label,
|
||||
description: typeof o.description === "string" ? o.description : undefined,
|
||||
preview: readOptionalString(o, "preview"),
|
||||
});
|
||||
}
|
||||
questions.push({
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
@@ -29,6 +30,9 @@ interface QuestionFormCardProps {
|
||||
}
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
// Native previews are uncapped: inside the option's Pressable, Android never hands a
|
||||
// drag to the preview's nested scroll, so lines past a cap would be unreachable.
|
||||
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
|
||||
|
||||
function getQuestionInputPlaceholder({
|
||||
question,
|
||||
@@ -133,6 +137,20 @@ function QuestionOptionRow({
|
||||
{option.description ? (
|
||||
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
||||
) : null}
|
||||
{option.preview ? (
|
||||
// Shown for every option at once so they can be compared before picking.
|
||||
// Long lines wrap, as in Claude Code, since most previews are prose.
|
||||
<ScrollableCodeSurface
|
||||
tone="surface0"
|
||||
maxHeight={OPTION_PREVIEW_MAX_HEIGHT}
|
||||
horizontal={false}
|
||||
selectable={false}
|
||||
textStyle={styles.optionPreviewText}
|
||||
testID="question-form-option-preview"
|
||||
>
|
||||
{option.preview}
|
||||
</ScrollableCodeSurface>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -704,6 +722,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionPreviewText: {
|
||||
lineHeight: 20,
|
||||
...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null),
|
||||
},
|
||||
selectionControl: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
type ActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import { density, densityIconBox } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||
@@ -93,7 +93,7 @@ import {
|
||||
SidebarWorkspaceMenu,
|
||||
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import {
|
||||
@@ -2114,6 +2114,7 @@ function ProjectModeList({
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
@@ -2413,7 +2414,12 @@ function ProjectModeList({
|
||||
<>
|
||||
{pinnedChats.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
<SidebarSectionHeader
|
||||
title={t("sidebar.pinned.title")}
|
||||
collapsed={pinnedCollapsed}
|
||||
onToggle={togglePinnedCollapsed}
|
||||
testID="sidebar-pinned-section-header"
|
||||
/>
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
<DraggableList
|
||||
@@ -2550,12 +2556,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
newWorkspaceGhostText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
newWorkspaceGhostTextHovered: {
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
@@ -2603,7 +2609,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
projectTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { density, densityFontSize } from "@/styles/density";
|
||||
|
||||
export function PinnedSectionHeader({
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={onToggle}
|
||||
style={styles.header}
|
||||
testID="sidebar-pinned-section-header"
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
|
||||
{hovered || isNative || isCompact ? (
|
||||
<DensityIcon
|
||||
icon={collapsed ? ChevronRight : ChevronDown}
|
||||
size="xs"
|
||||
color="foregroundMuted"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
minHeight: density.rowHeight,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: densityFontSize(theme, "sm"),
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
|
||||
import { density, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import { densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import {
|
||||
@@ -24,6 +24,9 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
|
||||
// Every surfaced status shares one badge shell, so the badge never changes size or position
|
||||
// between states. Only the thing inside it changes.
|
||||
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
|
||||
// workspaceBranchText) so the icon centers on the title rather than floating above it.
|
||||
const LEADING_SLOT_HEIGHT = 20;
|
||||
const STATUS_BADGE_SIZE = 12;
|
||||
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
|
||||
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
|
||||
@@ -273,10 +276,10 @@ const styles = StyleSheet.create((theme) => {
|
||||
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
|
||||
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot
|
||||
// only as tall as the icon puts it above the title and the kebab — which is why the
|
||||
// workspace status indicator shares the same `rowLineHeight`.
|
||||
// workspace status indicator is as tall as `LEADING_SLOT_HEIGHT`.
|
||||
projectLeadingVisualSlot: {
|
||||
width: densityIconBox("md"),
|
||||
height: density.rowLineHeight,
|
||||
height: LEADING_SLOT_HEIGHT,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import { density, densityIconBox } from "@/styles/density";
|
||||
|
||||
/**
|
||||
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so
|
||||
@@ -100,13 +100,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
textHovered: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import { density, densityIconBox } from "@/styles/density";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
|
||||
@@ -152,7 +152,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { density } from "@/styles/density";
|
||||
|
||||
/** A sidebar section label such as "Pinned" or "Workspaces". A section with `onToggle`
|
||||
* collapses from its title; children sit at the trailing edge as its actions. */
|
||||
export function SidebarSectionHeader({
|
||||
title,
|
||||
collapsed,
|
||||
onToggle,
|
||||
testID,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
testID?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{onToggle ? (
|
||||
<CollapsibleTitle
|
||||
title={title}
|
||||
collapsed={collapsed ?? false}
|
||||
onToggle={onToggle}
|
||||
testID={testID}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.title} testID={testID}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{children ? <View style={styles.actions}>{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleTitle({
|
||||
title,
|
||||
collapsed,
|
||||
onToggle,
|
||||
testID,
|
||||
}: {
|
||||
title: string;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={onToggle}
|
||||
style={styles.toggle}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{hovered || isNative || isCompact ? (
|
||||
<DensityIcon
|
||||
icon={collapsed ? ChevronRight : ChevronDown}
|
||||
size="xs"
|
||||
color="foregroundMuted"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
minHeight: density.rowHeight,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
// The title lands at spacing[2] to align with the rows' leading icons. Trailing actions
|
||||
// are icon buttons whose painted glyph stops inside the SVG box, so 4px aligns the ink
|
||||
// rather than the box to the row rail.
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: 4,
|
||||
userSelect: "none",
|
||||
},
|
||||
// The toggle reaches back over the header's leading padding so the whole label edge is pressable.
|
||||
toggle: {
|
||||
alignSelf: "stretch",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
marginLeft: -theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
@@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import { density, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
SidebarWorkspaceContextMenu,
|
||||
SidebarWorkspaceMenu,
|
||||
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
|
||||
@@ -146,6 +146,7 @@ export function SidebarStatusWorkspaceList({
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const { t } = useTranslation();
|
||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedWorkspaceGroupKeys,
|
||||
);
|
||||
@@ -202,7 +203,12 @@ export function SidebarStatusWorkspaceList({
|
||||
<>
|
||||
{pinnedWorkspaces.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
<SidebarSectionHeader
|
||||
title={t("sidebar.pinned.title")}
|
||||
collapsed={pinnedCollapsed}
|
||||
onToggle={togglePinnedCollapsed}
|
||||
testID="sidebar-pinned-section-header"
|
||||
/>
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
<DraggableList
|
||||
@@ -1099,7 +1105,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
statusGroupTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type SidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import { densityIconBox } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
@@ -453,6 +453,9 @@ export function SidebarWorkspaceTrailingActionOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
// The status dot centres on the title's first line, so both share its line height.
|
||||
const TITLE_LINE_HEIGHT = 20;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowContent: {
|
||||
position: "relative",
|
||||
@@ -481,7 +484,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceStatusDot: {
|
||||
position: "relative",
|
||||
width: densityIconBox("md"),
|
||||
height: density.rowLineHeight,
|
||||
height: TITLE_LINE_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
@@ -513,9 +516,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// to the meta row, so it takes the full width the trailing slot leaves behind.
|
||||
workspaceBranchText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "400",
|
||||
lineHeight: density.rowLineHeight,
|
||||
lineHeight: TITLE_LINE_HEIGHT,
|
||||
opacity: 0.76,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getAutocompleteFallbackIndex,
|
||||
getAutocompleteScrollOffset,
|
||||
orderAutocompleteOptions,
|
||||
} from "./autocomplete-utils";
|
||||
import { getAutocompleteFallbackIndex, orderAutocompleteOptions } from "./autocomplete-utils";
|
||||
|
||||
const OPTIONS = ["alpha", "beta", "gamma"];
|
||||
|
||||
@@ -28,27 +24,3 @@ describe("getAutocompleteFallbackIndex", () => {
|
||||
expect(getAutocompleteFallbackIndex(3, "below-input")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAutocompleteScrollOffset", () => {
|
||||
it("scrolls up when the active item is above the viewport", () => {
|
||||
expect(
|
||||
getAutocompleteScrollOffset({
|
||||
currentOffset: 120,
|
||||
viewportHeight: 80,
|
||||
itemTop: 90,
|
||||
itemHeight: 20,
|
||||
}),
|
||||
).toBe(90);
|
||||
});
|
||||
|
||||
it("scrolls down when the active item is below the viewport", () => {
|
||||
expect(
|
||||
getAutocompleteScrollOffset({
|
||||
currentOffset: 0,
|
||||
viewportHeight: 100,
|
||||
itemTop: 150,
|
||||
itemHeight: 24,
|
||||
}),
|
||||
).toBe(74);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,28 +29,3 @@ export function getAutocompleteNextIndex(args: {
|
||||
}): number {
|
||||
return getNextActiveIndex(args);
|
||||
}
|
||||
|
||||
export function getAutocompleteScrollOffset(args: {
|
||||
currentOffset: number;
|
||||
viewportHeight: number;
|
||||
itemTop: number;
|
||||
itemHeight: number;
|
||||
}): number {
|
||||
if (args.viewportHeight <= 0) {
|
||||
return args.currentOffset;
|
||||
}
|
||||
|
||||
const itemBottom = args.itemTop + args.itemHeight;
|
||||
const viewportTop = args.currentOffset;
|
||||
const viewportBottom = args.currentOffset + args.viewportHeight;
|
||||
|
||||
if (args.itemTop < viewportTop) {
|
||||
return Math.max(0, args.itemTop);
|
||||
}
|
||||
|
||||
if (itemBottom > viewportBottom) {
|
||||
return Math.max(0, itemBottom - args.viewportHeight);
|
||||
}
|
||||
|
||||
return args.currentOffset;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { File, Folder } from "lucide-react-native";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
|
||||
import { getScrollOffsetToRevealItem } from "./scroll-reveal";
|
||||
|
||||
export interface AutocompleteOption {
|
||||
id: string;
|
||||
@@ -146,7 +146,7 @@ export function Autocomplete({
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOffset = getAutocompleteScrollOffset({
|
||||
const nextOffset = getScrollOffsetToRevealItem({
|
||||
currentOffset: scrollOffsetRef.current,
|
||||
viewportHeight: viewportHeightRef.current,
|
||||
itemTop: layout.top,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
@@ -154,3 +166,5 @@ 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;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getScrollOffsetToRevealItem } from "./scroll-reveal";
|
||||
|
||||
describe("getScrollOffsetToRevealItem", () => {
|
||||
it("keeps the offset when the item is already inside the viewport", () => {
|
||||
expect(
|
||||
getScrollOffsetToRevealItem({
|
||||
currentOffset: 40,
|
||||
viewportHeight: 200,
|
||||
itemTop: 100,
|
||||
itemHeight: 40,
|
||||
}),
|
||||
).toBe(40);
|
||||
});
|
||||
|
||||
it("scrolls up to the item top when the item is above the viewport", () => {
|
||||
expect(
|
||||
getScrollOffsetToRevealItem({
|
||||
currentOffset: 300,
|
||||
viewportHeight: 200,
|
||||
itemTop: 240,
|
||||
itemHeight: 40,
|
||||
}),
|
||||
).toBe(240);
|
||||
});
|
||||
|
||||
it("scrolls down just far enough to reveal the item bottom", () => {
|
||||
expect(
|
||||
getScrollOffsetToRevealItem({
|
||||
currentOffset: 0,
|
||||
viewportHeight: 200,
|
||||
itemTop: 380,
|
||||
itemHeight: 40,
|
||||
}),
|
||||
).toBe(220);
|
||||
});
|
||||
|
||||
it("stays put before the viewport has been measured", () => {
|
||||
expect(
|
||||
getScrollOffsetToRevealItem({
|
||||
currentOffset: 120,
|
||||
viewportHeight: 0,
|
||||
itemTop: 400,
|
||||
itemHeight: 40,
|
||||
}),
|
||||
).toBe(120);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type RefObject } from "react";
|
||||
import type {
|
||||
LayoutChangeEvent,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
ScrollView,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
export function getScrollOffsetToRevealItem(args: {
|
||||
currentOffset: number;
|
||||
viewportHeight: number;
|
||||
itemTop: number;
|
||||
itemHeight: number;
|
||||
}): number {
|
||||
if (args.viewportHeight <= 0) {
|
||||
return args.currentOffset;
|
||||
}
|
||||
|
||||
const itemBottom = args.itemTop + args.itemHeight;
|
||||
const viewportTop = args.currentOffset;
|
||||
const viewportBottom = args.currentOffset + args.viewportHeight;
|
||||
|
||||
if (args.itemTop < viewportTop) {
|
||||
return Math.max(0, args.itemTop);
|
||||
}
|
||||
|
||||
if (itemBottom > viewportBottom) {
|
||||
return Math.max(0, itemBottom - args.viewportHeight);
|
||||
}
|
||||
|
||||
return args.currentOffset;
|
||||
}
|
||||
|
||||
export interface RevealActiveItemHandlers {
|
||||
activeItemRef: RefObject<View | null>;
|
||||
contentRef: RefObject<View | null>;
|
||||
onItemLayout: () => void;
|
||||
onViewportLayout: (event: LayoutChangeEvent) => void;
|
||||
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveals the keyboard-selected row using its current position in the scroll
|
||||
* content. Measuring on selection/list changes also covers retained rows that
|
||||
* move or keep their size without emitting another onLayout event on web.
|
||||
* A list replacement resets to the top only when selection restarts there.
|
||||
* Bottom-anchored lists share only getScrollOffsetToRevealItem.
|
||||
*/
|
||||
export function useRevealActiveItem(options: {
|
||||
activeIndex: number;
|
||||
listKey: string;
|
||||
scrollRef: RefObject<ScrollView | null>;
|
||||
}): RevealActiveItemHandlers {
|
||||
const { activeIndex, listKey, scrollRef } = options;
|
||||
const activeItemRef = useRef<View | null>(null);
|
||||
const contentRef = useRef<View | null>(null);
|
||||
const viewportHeightRef = useRef(0);
|
||||
const offsetRef = useRef(0);
|
||||
const measurementRef = useRef(0);
|
||||
|
||||
const reveal = useCallback(() => {
|
||||
const request = ++measurementRef.current;
|
||||
const item = activeItemRef.current;
|
||||
const scroll = scrollRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!item || !scroll || !content) return;
|
||||
|
||||
item.measureLayout(content, (_left, top, _width, height) => {
|
||||
if (request !== measurementRef.current || activeItemRef.current !== item) return;
|
||||
const nextOffset = getScrollOffsetToRevealItem({
|
||||
currentOffset: offsetRef.current,
|
||||
viewportHeight: viewportHeightRef.current,
|
||||
itemTop: top,
|
||||
itemHeight: height,
|
||||
});
|
||||
if (Math.abs(nextOffset - offsetRef.current) < 1) return;
|
||||
offsetRef.current = nextOffset;
|
||||
scroll.scrollTo({ y: nextOffset, animated: false });
|
||||
});
|
||||
}, [scrollRef]);
|
||||
|
||||
const prevListKeyRef = useRef(listKey);
|
||||
useLayoutEffect(() => {
|
||||
if (prevListKeyRef.current !== listKey && activeIndex === 0) {
|
||||
offsetRef.current = 0;
|
||||
scrollRef.current?.scrollTo({ y: 0, animated: false });
|
||||
}
|
||||
prevListKeyRef.current = listKey;
|
||||
reveal();
|
||||
return () => {
|
||||
// Measurements are asynchronous; a previous selection must not scroll us.
|
||||
measurementRef.current += 1;
|
||||
};
|
||||
}, [activeIndex, listKey, reveal, scrollRef]);
|
||||
|
||||
const onViewportLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
viewportHeightRef.current = event.nativeEvent.layout.height;
|
||||
reveal();
|
||||
},
|
||||
[reveal],
|
||||
);
|
||||
|
||||
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
offsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
|
||||
return { activeItemRef, contentRef, onItemLayout: reveal, onViewportLayout, onScroll };
|
||||
}
|
||||
@@ -104,6 +104,13 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
});
|
||||
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
|
||||
},
|
||||
setSelection: (selection) => {
|
||||
if (inputRef.current?.setSelection) {
|
||||
inputRef.current.setSelection(selection.start, selection.end);
|
||||
return;
|
||||
}
|
||||
inputRef.current?.setNativeProps?.({ selection });
|
||||
},
|
||||
reset: () => {
|
||||
textRef.current = "";
|
||||
const autoFocus = inputRef.current?.isFocused?.() ?? false;
|
||||
|
||||
@@ -77,6 +77,10 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
input.setSelectionRange(selection.start, selection.end);
|
||||
}
|
||||
},
|
||||
setSelection: (selection) => {
|
||||
const input = inputRef.current as WebTextInputElement | null;
|
||||
input?.setSelectionRange?.(selection.start, selection.end);
|
||||
},
|
||||
reset: () => {
|
||||
textRef.current = "";
|
||||
const input = inputRef.current as WebTextInputElement | null;
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface EditingTextInputHandle {
|
||||
isFocused(): boolean;
|
||||
getText(): string;
|
||||
replaceText(text: string, selection?: { start: number; end: number }): void;
|
||||
setSelection(selection: { start: number; end: number }): void;
|
||||
/** Clear the editor and reset its intrinsic layout, preserving focus intent. */
|
||||
reset(): void;
|
||||
getNativeRef(): unknown;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ComposerDockBackground } from "./internal/background";
|
||||
export { ComposerDockBackground } from "./internal/background";
|
||||
export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -8,22 +9,22 @@ import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
|
||||
import { KeyboardTranslateView } from "@/keyboard/shift";
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
|
||||
import { StyleSheet as ThemedStyleSheet } from "react-native-unistyles";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
useDerivedValue,
|
||||
useAnimatedReaction,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveInlineComposerCapacity,
|
||||
resolveComposerSlotCapacity,
|
||||
updateComposerCapacity,
|
||||
type ComposerCapacity,
|
||||
} from "./internal/capacity";
|
||||
|
||||
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
|
||||
const ViewportSizing = createContext<SharedValue<ComposerCapacity | undefined> | null>(null);
|
||||
|
||||
interface ComposerViewportProps extends ViewProps {
|
||||
bottomInset?: number;
|
||||
@@ -55,7 +56,6 @@ function ComposerViewport({
|
||||
},
|
||||
[],
|
||||
);
|
||||
const capacity = useDerivedValue(() => sizing.value?.capacity);
|
||||
const measureViewport = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
measuredHeight.value = event.nativeEvent.layout.height;
|
||||
@@ -66,20 +66,24 @@ function ComposerViewport({
|
||||
|
||||
return (
|
||||
<View testID="composer-viewport" {...props} onLayout={measureViewport} collapsable={false}>
|
||||
<ViewportCapacity.Provider value={capacity}>{children}</ViewportCapacity.Provider>
|
||||
<ViewportSizing.Provider value={sizing}>{children}</ViewportSizing.Provider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bound the complete composer (including its controls), not just the text input. */
|
||||
function ComposerViewportContent({ style, ...props }: ViewProps) {
|
||||
function ComposerViewportContent({
|
||||
style,
|
||||
fullscreen = false,
|
||||
...props
|
||||
}: ViewProps & { fullscreen?: boolean }) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const capacity = useContext(ViewportCapacity);
|
||||
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
|
||||
const sizing = useContext(ViewportSizing);
|
||||
if (!sizing) throw new Error("ComposerViewportContent requires ComposerViewport");
|
||||
const constraint = useAnimatedStyle(() => ({
|
||||
// Only viewport changes and keyboard start/end events trigger layout. Motion
|
||||
// stays in KeyboardTranslateView and never changes this constraint per frame.
|
||||
maxHeight: resolveInlineComposerCapacity(capacity.value, compact),
|
||||
maxHeight: resolveComposerSlotCapacity(sizing.value, { compact, fullscreen }),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -108,37 +112,71 @@ function useCenteredClearance(safeAreaBottom: number) {
|
||||
interface ComposerDockProps {
|
||||
children: [ReactNode, ReactNode, ReactNode?];
|
||||
centered?: boolean;
|
||||
/**
|
||||
* Fullscreen is a layout mode of the same mounted composer: the slot fills the
|
||||
* stationary viewport over the content and lays out the keyboard itself, so the
|
||||
* surface stops translating. The screen that owns the chrome hides it.
|
||||
*/
|
||||
fullscreen?: boolean;
|
||||
}
|
||||
|
||||
/** The stationary viewport, translated surface, and bounded composer are one owner. */
|
||||
export function ComposerDock({
|
||||
children: [content, composer, overlay],
|
||||
centered = false,
|
||||
fullscreen = false,
|
||||
}: ComposerDockProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
|
||||
const centeredClearance = useCenteredClearance(insets.bottom);
|
||||
// Preserve the existing centered form's visual balance on tablets.
|
||||
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||
const bottomInset = centered && !fullscreen ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||
const fullscreenSurface = [
|
||||
themedStyles.fullscreenComposer,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
},
|
||||
];
|
||||
if (centered) {
|
||||
// Same tree in both modes. Fullscreen stops centering and translating, hides the
|
||||
// setup fields, and lets the composer slot take the whole stationary viewport.
|
||||
return (
|
||||
<ComposerViewport
|
||||
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
|
||||
style={
|
||||
fullscreen
|
||||
? dockStyles.viewport
|
||||
: [dockStyles.centeredViewport, { paddingBottom: bottomInset }]
|
||||
}
|
||||
bottomInset={bottomInset}
|
||||
centered
|
||||
onLayout={centeredClearance.measureViewport}
|
||||
>
|
||||
<KeyboardTranslateView
|
||||
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
|
||||
style={
|
||||
fullscreen ? dockStyles.surface : [dockStyles.centered, { maxWidth: contentMaxWidth }]
|
||||
}
|
||||
enabled={!fullscreen}
|
||||
bottomClearance={centeredClearance.value}
|
||||
onLayout={centeredClearance.measureForm}
|
||||
>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
|
||||
<ComposerViewportContent
|
||||
style={fullscreen ? dockStyles.surface : dockStyles.composer}
|
||||
fullscreen={fullscreen}
|
||||
>
|
||||
<ScrollView
|
||||
style={fullscreen ? dockStyles.hiddenSetup : dockStyles.setup}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{content}
|
||||
</ScrollView>
|
||||
<ComposerViewportContent style={dockStyles.centeredComposer}>
|
||||
{composer}
|
||||
<ComposerViewportContent
|
||||
style={fullscreen ? dockStyles.surface : dockStyles.centeredComposer}
|
||||
fullscreen={fullscreen}
|
||||
>
|
||||
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
|
||||
</ComposerViewportContent>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
@@ -148,7 +186,7 @@ export function ComposerDock({
|
||||
}
|
||||
return (
|
||||
<ComposerViewport style={dockStyles.viewport}>
|
||||
<KeyboardTranslateView style={dockStyles.surface}>
|
||||
<KeyboardTranslateView style={dockStyles.surface} enabled={!fullscreen}>
|
||||
<View
|
||||
testID="composer-dock-content"
|
||||
collapsable={false}
|
||||
@@ -162,8 +200,19 @@ export function ComposerDock({
|
||||
{content}
|
||||
</View>
|
||||
</View>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<View style={[dockStyles.composer, { paddingBottom: insets.bottom }]}>{composer}</View>
|
||||
<ComposerViewportContent
|
||||
style={fullscreen ? dockStyles.fullscreenSlot : dockStyles.composer}
|
||||
fullscreen={fullscreen}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
fullscreen
|
||||
? fullscreenSurface
|
||||
: [dockStyles.composer, { paddingBottom: insets.bottom }]
|
||||
}
|
||||
>
|
||||
{composer}
|
||||
</View>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
</KeyboardTranslateView>
|
||||
@@ -178,9 +227,16 @@ const dockStyles = StyleSheet.create({
|
||||
surface: { flex: 1 },
|
||||
content: { flex: 1, justifyContent: "flex-end" },
|
||||
composer: { width: "100%", flexShrink: 1 },
|
||||
fullscreenSlot: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 },
|
||||
centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||
centered: { flexShrink: 1, width: "100%" },
|
||||
// Reserve the composer's own capped height before the setup scroll view shrinks.
|
||||
centeredComposer: { width: "100%", flexShrink: 0 },
|
||||
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
|
||||
hiddenSetup: { display: "none" },
|
||||
});
|
||||
|
||||
// The fullscreen composer paints its own surface, including under the safe areas.
|
||||
const themedStyles = ThemedStyleSheet.create((theme) => ({
|
||||
fullscreenComposer: { flex: 1, backgroundColor: theme.colors.surface1 },
|
||||
}));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user