diff --git a/.gitignore b/.gitignore
index 53a2169c67..a85ff1fd8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -85,6 +85,8 @@ valknut-report.json/
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
packages/protocol/src/generated/validation/*.aot.ts
+packages/protocol/src/generated/validation/ws-outbound-schema-metadata.ts
+packages/protocol/codegen/generated/
/artifacts
packages/desktop/.cache/
@@ -93,3 +95,6 @@ app.json
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
*.heapsnapshot
+
+# Project-local Serena configuration and memories.
+.serena/
diff --git a/.oxlintrc.json b/.oxlintrc.json
index d1566195e9..4582602a97 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -252,7 +252,6 @@
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/draggable-list.native.tsx",
"packages/app/src/components/headers/back-header.tsx",
- "packages/app/src/components/headers/menu-header.tsx",
"packages/app/src/components/headers/screen-header.tsx",
"packages/app/src/components/host-status-dot.tsx",
"packages/app/src/components/hosts/host-picker.tsx",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5219bda03e..80dc5d17e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,35 +1,222 @@
# Changelog
-## 0.11.0-beta.4 - 2026-10-05
+## 0.12.0-beta.1 - 2026-10-16
+
+Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
+
+**Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds
+
+### Added
+
+- Added background workspaces, hidden from the sidebar unless Show background is on ([#5039](https://github.com/getpaseo/paseo/pull/5039), [#7141](https://github.com/getpaseo/paseo/pull/7141) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
+- Added a Send feedback button to the diff pane, so review comments reach an agent without opening its chat ([#6455](https://github.com/getpaseo/paseo/pull/6455))
+- Added a fullscreen composer on phones ([#6531](https://github.com/getpaseo/paseo/pull/6531), [#6902](https://github.com/getpaseo/paseo/pull/6902), [#6945](https://github.com/getpaseo/paseo/pull/6945), [#6987](https://github.com/getpaseo/paseo/pull/6987), [#7130](https://github.com/getpaseo/paseo/pull/7130), [#7170](https://github.com/getpaseo/paseo/pull/7170))
+- Added the sender's name to messages one agent sends another ([#6373](https://github.com/getpaseo/paseo/pull/6373))
+- Added Claude Mythos 5.1 when the installed Claude Code advertises it ([#6403](https://github.com/getpaseo/paseo/pull/6403))
+- Added option previews to Claude question cards ([#6171](https://github.com/getpaseo/paseo/pull/6171) by [@gabrielgiordan](https://github.com/gabrielgiordan))
+- Added `paseo usage` to show account quotas, balances, and reset times from the CLI ([#6474](https://github.com/getpaseo/paseo/pull/6474))
+
+### Improved
+
+- Changed text selection in replies on iOS and Android to cross paragraphs, headings, and lists ([#6973](https://github.com/getpaseo/paseo/pull/6973))
+- Added a copy button to code blocks on iOS and Android ([#6973](https://github.com/getpaseo/paseo/pull/6973))
+- Changed wide Markdown tables on iOS and Android to scroll sideways ([#6973](https://github.com/getpaseo/paseo/pull/6973))
+- Changed phone layouts to a floating header ([#6431](https://github.com/getpaseo/paseo/pull/6431))
+- Moved New agent and New terminal on phones into the tab switcher ([#6431](https://github.com/getpaseo/paseo/pull/6431))
+- Moved workspace scripts on phones into ⋯ → Scripts, which shows what is running ([#7122](https://github.com/getpaseo/paseo/pull/7122))
+- Changed phones to open files and subagents in a closable sheet, with Open as tab for subagents ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958))
+- Changed diff comments on phones to open in a sheet instead of an editor inside the code ([#6455](https://github.com/getpaseo/paseo/pull/6455))
+- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
+- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
+- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
+- Changed Gitea and Forgejo fork PR checkouts to include the fork owner in the branch name, such as `bob/patch-1` ([#6829](https://github.com/getpaseo/paseo/pull/6829) by [@alexanderadam](https://github.com/alexanderadam))
+
+### Plugins
+
+- Fixed plugins that import Paseo protocol types failing to install with "Could not resolve type dependency" ([#7023](https://github.com/getpaseo/paseo/pull/7023))
+- Fixed `providers.waitForReady` throwing in plugins on the mobile app ([#6760](https://github.com/getpaseo/paseo/pull/6760) by [@GeorgeR](https://github.com/GeorgeR))
+- Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD))
+- Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
+
+### Fixed
+
+- Fixed iOS replies with list items that render blank, and text that keeps a stale width after a resize or rotation ([#6973](https://github.com/getpaseo/paseo/pull/6973))
+- Fixed plugin hosts, the CLI, and the desktop app growing to about 1 GB of memory after running for 30 to 75 minutes ([#6847](https://github.com/getpaseo/paseo/pull/6847) by [@babo-jeonguk](https://github.com/babo-jeonguk))
+- Fixed the daemon running out of memory during long OpenCode v2 reply or reasoning streams ([#6262](https://github.com/getpaseo/paseo/pull/6262))
+- Fixed archived ACP agents starting a provider process that kept running each time their timeline loaded ([#6766](https://github.com/getpaseo/paseo/pull/6766) by [@apple-ouyang](https://github.com/apple-ouyang))
+- Fixed oversized attachments disconnecting the host in a loop instead of failing with a size error ([#6285](https://github.com/getpaseo/paseo/pull/6285))
+- Fixed Download in Files failing on hosts connected over relay, SSH, or a socket ([#6595](https://github.com/getpaseo/paseo/pull/6595) by [@odoo-mvds](https://github.com/odoo-mvds), [@YASICJUNWOO](https://github.com/YASICJUNWOO), [@JrDw0](https://github.com/JrDw0))
+- Fixed Changes showing no diff lines with custom Git diff settings, such as `color.diff=always` or an external diff command ([#6536](https://github.com/getpaseo/paseo/pull/6536), [#6562](https://github.com/getpaseo/paseo/pull/6562) by [@Mar-garet](https://github.com/Mar-garet))
+- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
+- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
+- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
+- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
+- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
+- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
+- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
+- Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone))
+- Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger))
+- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
+- Fixed browser screenshots failing the turn on OpenCode v2 agents ([#6330](https://github.com/getpaseo/paseo/pull/6330))
+- Fixed OpenCode v2 agents receiving "HTTP 500" instead of the Paseo tool error ([#6519](https://github.com/getpaseo/paseo/pull/6519))
+- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
+- Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr))
+- Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan))
+- Fixed Cursor agents showing no plan usage in their context details ([#6353](https://github.com/getpaseo/paseo/pull/6353) by [@gengjiawen](https://github.com/gengjiawen))
+- Fixed the Claude context meter counting only output tokens when a proxy reports input usage at the end of the response ([#6703](https://github.com/getpaseo/paseo/pull/6703) by [@kywoo26](https://github.com/kywoo26))
+- Fixed the Claude context meter showing a 1M maximum after switching back to a 200k model ([#6814](https://github.com/getpaseo/paseo/pull/6814))
+- Fixed the context meter staying empty during the first turn on custom Claude `[1m]` models ([#6767](https://github.com/getpaseo/paseo/pull/6767))
+- Fixed a schedule never running again after one of its runs could not be recorded ([#6334](https://github.com/getpaseo/paseo/pull/6334))
+- Fixed schedules sharing a cron minute running a slot twice and skipping the next occurrence ([#6532](https://github.com/getpaseo/paseo/pull/6532) by [@keithpk](https://github.com/keithpk))
+- Fixed Committed showing no changes for worktrees created from an existing branch ([#6462](https://github.com/getpaseo/paseo/pull/6462) by [@cr-2takumax](https://github.com/cr-2takumax))
+- Fixed Discard and commit diffs in Changes for workspaces opened at a repository subdirectory ([#6777](https://github.com/getpaseo/paseo/pull/6777), [#6801](https://github.com/getpaseo/paseo/pull/6801), [#6882](https://github.com/getpaseo/paseo/pull/6882) by [@Mar-garet](https://github.com/Mar-garet))
+- Fixed renamed and edited files showing every line as added in Changes ([#6559](https://github.com/getpaseo/paseo/pull/6559))
+- Fixed renamed files opened from a commit showing as new files ([#6895](https://github.com/getpaseo/paseo/pull/6895))
+- Fixed Changes showing no diff for filenames containing quotes, backslashes, or tabs ([#6583](https://github.com/getpaseo/paseo/pull/6583) by [@Mar-garet](https://github.com/Mar-garet))
+- Fixed Changes dropping removed lines that start with `--`, such as SQL comments ([#6569](https://github.com/getpaseo/paseo/pull/6569) by [@Mar-garet](https://github.com/Mar-garet))
+- Fixed the Diff tab staying on a spinner after the first edit in a clean workspace ([#6745](https://github.com/getpaseo/paseo/pull/6745))
+- Fixed worktree creation failing after running `git init` in a project added before Git ([#6785](https://github.com/getpaseo/paseo/pull/6785))
+- Fixed new worktrees not opening the terminals configured in `paseo.json` after setup ([#6999](https://github.com/getpaseo/paseo/pull/6999) by [@davidyang](https://github.com/davidyang))
+- Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542))
+- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
+- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
+- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
+- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
+- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
+- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
+- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
+- Fixed iOS sidebar rows staying highlighted and swallowing the next tap after a brief hold ([#7159](https://github.com/getpaseo/paseo/pull/7159) by [@kassamkhoja](https://github.com/kassamkhoja))
+- Fixed Android chats scrolling when you long-press to select text ([#7131](https://github.com/getpaseo/paseo/pull/7131) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
+- Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150))
+- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
+- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
+- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
+- Fixed letter shortcuts not running with non-Latin keyboard layouts such as Russian ([#6755](https://github.com/getpaseo/paseo/pull/6755))
+- Fixed shortcut recording saving the QWERTY key instead of the typed letter on layouts such as Dvorak ([#6652](https://github.com/getpaseo/paseo/pull/6652))
+- Fixed rebinding Jump to tab or Jump to workspace keeping only the pressed number instead of 1–9; record existing overrides again ([#7065](https://github.com/getpaseo/paseo/pull/7065) by [@dbunk903](https://github.com/dbunk903))
+- Fixed Previous and Next workspace shortcuts stopping at workspace 9 ([#6586](https://github.com/getpaseo/paseo/pull/6586) by [@IEatCodeDaily](https://github.com/IEatCodeDaily))
+- Fixed unreadable Android status bar icons in the Light theme and after the system theme changes ([#6707](https://github.com/getpaseo/paseo/pull/6707) by [@terayang](https://github.com/terayang))
+- Fixed the pairing QR code getting clipped on iOS and Android ([#6758](https://github.com/getpaseo/paseo/pull/6758) by [@fdkevin0](https://github.com/fdkevin0))
+- Fixed a `write EPIPE` error dialog on Linux desktop when the launcher stops reading console output ([#6639](https://github.com/getpaseo/paseo/pull/6639))
+- Fixed `paseo permit allow` and `deny` reporting success over SSH while the request stayed pending ([#6607](https://github.com/getpaseo/paseo/pull/6607) by [@budivoogt](https://github.com/budivoogt))
+- Fixed files and folders whose names start with two dots, like `..notes.txt`, being hidden and rejected ([#6604](https://github.com/getpaseo/paseo/pull/6604), [#6720](https://github.com/getpaseo/paseo/pull/6720) by [@Mar-garet](https://github.com/Mar-garet))
+- Fixed Markdown previews dropping table rows after a `<` that isn't HTML, such as `List
+
+
## はじめかた
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 のホームページに掲載します。
-
-
+Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをこの README の冒頭と、paseo.sh のトップページおよびドキュメントに掲載します。
## 関連プロジェクト
diff --git a/README.ko.md b/README.ko.md
index 60581767cd..7f2098221d 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -47,6 +47,17 @@
- **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다.
- **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다.
+## 스폰서
+
+
+
+
+
+
Cloud coding agentsPaseo のスポンサーになる
+ Paseo のスポンサーになる
+
+
+
## 시작하기
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 홈페이지에 게재할 수 있습니다.
-
-
+Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이 README 상단과 paseo.sh 홈페이지 및 문서에 게재할 수 있습니다.
## 관련 프로젝트
diff --git a/README.md b/README.md
index aa0a841e7e..1f6df3c3e4 100644
--- a/README.md
+++ b/README.md
@@ -39,21 +39,44 @@
+
+
+
Cloud coding agentsPaseo 스폰서 되기
+ Paseo 스폰서 되기
+
Cloud coding agents |
+ Become a sponsor | +Become a sponsor | +
Cloud coding agents |
+ 成为 Paseo 赞助商 | +成为 Paseo 赞助商 | +
https://example.com/generated");
- expect(clipboard.html).not.toContain(
+ expect(clipboard.html).toContain(
'https://example.com/generated',
);
expect(clipboard.html).toContain('');
@@ -649,3 +671,55 @@ test("copying an assistant selection preserves Markdown structure and links", as
await agent.cleanup();
}
});
+
+test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({
+ context,
+ page,
+}) => {
+ const image =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "assistant-selection-copy-image-",
+ title: "Assistant selection copy image",
+ initialPrompt: "Render the image fixture.",
+ featureValues: {
+ mockAssistantResponse: [
+ "Before the image.",
+ "",
+ ``,
+ "",
+ "After the image.",
+ ].join("\n"),
+ },
+ });
+
+ try {
+ await allowRichClipboard(context);
+ await agent.client.waitForAgentUpsert(
+ agent.agentId,
+ (snapshot) => snapshot.status === "idle",
+ 30_000,
+ );
+ await openAgentRoute(page, agent);
+ await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1);
+
+ await selectAssistantTextRange(page, "Before the image.", "After the image.");
+ await copySelection(page);
+
+ expect((await readRichClipboard(page)).plainText).toBe(
+ `Before the image.\n\n\n\nAfter the image.`,
+ );
+
+ await selectAssistantImage(page);
+ await copySelection(page);
+
+ expect((await readRichClipboard(page)).plainText).toBe(``);
+
+ await dragFromBelowImageTo(page, "After the");
+ await copySelection(page);
+
+ expect((await readRichClipboard(page)).plainText).toBe("After the");
+ } finally {
+ await agent.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts
index 89ee10af22..bc65ae65de 100644
--- a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts
+++ b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts
@@ -89,9 +89,11 @@ async function dismissStackedModelPickerWithBackdrop(page: Page) {
.getByTestId("agent-controls-settings-list")
.getByRole("button", { name: /Select model/ })
.click();
- await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible();
+ await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeInViewport();
await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24);
- await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
+ // In CI, Gorhom retains the dismissed sheet below the viewport.
+ // Dismissal is complete when it leaves the displayed surface.
+ await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeInViewport();
await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
}
@@ -139,7 +141,7 @@ test.describe("mobile bottom sheet reopen", () => {
await expect(sheet).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
- await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
+ await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeInViewport();
});
} finally {
await session.cleanup();
diff --git a/packages/app/e2e/browser/changes-first-edit.spec.ts b/packages/app/e2e/browser/changes-first-edit.spec.ts
new file mode 100644
index 0000000000..72c3890936
--- /dev/null
+++ b/packages/app/e2e/browser/changes-first-edit.spec.ts
@@ -0,0 +1,122 @@
+import { appendFile } from "node:fs/promises";
+import path from "node:path";
+import type { Page } from "@playwright/test";
+import { test, expect } from "../support/fixtures";
+import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
+
+function changesTree(page: Page) {
+ return page.getByTestId("changes-tree-panel").filter({ visible: true });
+}
+
+async function expectEmptyChanges(page: Page) {
+ await expect(changesTree(page).getByText("No changes to display", { exact: true })).toBeVisible();
+}
+
+async function editReadme(repoPath: string, text: string) {
+ await appendFile(path.join(repoPath, "README.md"), text);
+}
+
+async function expectReadmeChanges(page: Page, additions: number) {
+ await expect(changesTree(page).getByText("README.md", { exact: true })).toBeVisible({
+ timeout: 30_000,
+ });
+ await expect(changesTree(page)).toContainText(`+${additions}`);
+}
+
+async function openEmptyDiff(page: Page) {
+ await changesTree(page).getByTestId("changes-options-menu").click();
+ await page.getByTestId("changes-open-tab").click();
+ await expect(
+ page
+ .getByTestId("working-diff-panel")
+ .filter({ visible: true })
+ .getByText("No changes to display", { exact: true }),
+ ).toBeVisible();
+}
+
+async function expectReadmeDiff(page: Page, additions: number) {
+ await expect(
+ page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
+ ).toHaveAccessibleName(`README.md, +${additions}, -0`, { timeout: 30_000 });
+}
+
+async function openDiffFromNewTabMenu(page: Page) {
+ await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first().click();
+ await page.getByTestId("workspace-new-tab-menu-diff").filter({ visible: true }).first().click();
+ await expect(
+ page
+ .getByTestId("working-diff-panel")
+ .filter({ visible: true })
+ .getByText("No changes to display", { exact: true }),
+ ).toBeVisible();
+}
+
+async function refreshDiff(page: Page) {
+ await page
+ .getByTestId("working-diff-panel")
+ .filter({ visible: true })
+ .getByTestId("changes-refresh")
+ .click();
+}
+
+async function reloadWorkspace(page: Page) {
+ await page.reload();
+}
+
+test("Changes follows the first edit to a clean workspace without reloading", async ({
+ page,
+ withWorkspace,
+}) => {
+ const workspace = await withWorkspace();
+ await page.setViewportSize({ width: 1400, height: 900 });
+ await workspace.navigateTo();
+ await openChangesTreePanel(page);
+ await expectEmptyChanges(page);
+
+ await editReadme(workspace.repoPath, "first edit\n");
+ await expectReadmeChanges(page, 1);
+
+ await editReadme(workspace.repoPath, "second edit\n");
+ await expectReadmeChanges(page, 2);
+});
+
+test("the full diff follows the first edit to a clean workspace without reloading", async ({
+ page,
+ withWorkspace,
+}) => {
+ const workspace = await withWorkspace();
+ await page.setViewportSize({ width: 1400, height: 900 });
+ await workspace.navigateTo();
+ await openChangesTreePanel(page);
+ await expectEmptyChanges(page);
+ await openEmptyDiff(page);
+
+ await editReadme(workspace.repoPath, "first edit\n");
+ await expectReadmeDiff(page, 1);
+
+ await editReadme(workspace.repoPath, "second edit\n");
+ await expectReadmeDiff(page, 2);
+});
+
+test("a directly opened Diff tab follows the first edit without an active Changes tree", async ({
+ page,
+ withWorkspace,
+}) => {
+ const workspace = await withWorkspace();
+ await page.setViewportSize({ width: 1400, height: 900 });
+ await workspace.navigateTo();
+ await openDiffFromNewTabMenu(page);
+
+ await editReadme(workspace.repoPath, "first edit\n");
+ await expectReadmeDiff(page, 1);
+
+ await editReadme(workspace.repoPath, "second edit\n");
+ await expectReadmeDiff(page, 2);
+
+ await refreshDiff(page);
+ await expectReadmeDiff(page, 2);
+ await reloadWorkspace(page);
+ await expectReadmeDiff(page, 2);
+ await editReadme(workspace.repoPath, "third edit\n");
+ await expectReadmeDiff(page, 3);
+});
diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts
index 637d1b633b..377adbb3b6 100644
--- a/packages/app/e2e/browser/changes-pane.spec.ts
+++ b/packages/app/e2e/browser/changes-pane.spec.ts
@@ -683,7 +683,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
- await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
+ await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0");
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
diff --git a/packages/app/e2e/browser/chat-find.spec.ts b/packages/app/e2e/browser/chat-find.spec.ts
index 0ce6c561ed..b29a5f5b89 100644
--- a/packages/app/e2e/browser/chat-find.spec.ts
+++ b/packages/app/e2e/browser/chat-find.spec.ts
@@ -33,9 +33,10 @@ const STREAMED_RESPONSE = [
const RESPONSE =
'İ😀hello **world**; hello *world*.\n\n[hello & world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
-// One message far taller than the viewport, so its first and last paragraph rows are
-// never in the virtualizer's window at the same time.
+// One message taller than the viewport, exercising navigation between distant
+// matches inside the same rendered row.
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
+const VIRTUALIZED_TURN_COUNT = 60;
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`;
@@ -322,11 +323,11 @@ test("finds a hit in a message that streamed while the chat was open", async ({
const chat = await openChatStreamingMultiBlockReply(page);
try {
await openChatFind(page);
- // The only hit is in the first paragraph, which is no longer the row that grows.
+ // The first-paragraph hit must remain searchable while the same message grows.
await expectFindStatus(page, "alpha-needle", "1 of 1");
await chat.replyFinished();
await expectFindStatus(page, "beta-needle", "1 of 1");
- // One message, two Markdown blocks, one count across both.
+ // One message, two paragraphs, one count across both.
await expectFindStatus(page, "pair-needle", "1 of 2");
await expectNextMatch(page, "2 of 2", "pair-needle");
await closeFind(page);
@@ -346,12 +347,14 @@ async function openChatWithVirtualizedTallMessage(page: Page): Promise {
@@ -370,9 +372,9 @@ test("counts every occurrence of a virtualized message and steps between them",
const chat = await openChatWithVirtualizedTallMessage(page);
try {
await openChatFind(page);
- await expectFindStatus(page, "span-needle", "1 of 12");
+ await expectFindStatus(page, "span-needle", `1 of ${VIRTUALIZED_TURN_COUNT * 2}`);
await expectHighlight(page, "span-needle");
- await expectNextMatch(page, "2 of 12", "span-needle");
+ await expectNextMatch(page, `2 of ${VIRTUALIZED_TURN_COUNT * 2}`, "span-needle");
await closeFind(page);
} finally {
await chat.cleanup();
diff --git a/packages/app/e2e/browser/chat-outline.spec.ts b/packages/app/e2e/browser/chat-outline.spec.ts
index 9dd3c33313..640b141f53 100644
--- a/packages/app/e2e/browser/chat-outline.spec.ts
+++ b/packages/app/e2e/browser/chat-outline.spec.ts
@@ -25,6 +25,12 @@ import {
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import { createCreationScenario } from "../support/helpers/creation";
+import {
+ attachPromptTimelineEvidence,
+ expectPromptsDrawnByPlugin,
+ expectVirtualizedPluginPromptVisible,
+ withPromptTimelinePlugin,
+} from "../support/helpers/plugin-timeline";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
@@ -105,6 +111,60 @@ test.describe("desktop chat outline", () => {
}
});
+ test("jumps to prompts a timeline plugin draws", async ({ page }) => {
+ test.setTimeout(180_000);
+ const agent = await seedLongMockAgentTimeline({ turns: 80 });
+ try {
+ await withPromptTimelinePlugin(async () => {
+ await page.setViewportSize(WIDE_VIEWPORT);
+ await openAgentTimeline(page, agent);
+ await expectChatOutlinePrompts(page, 80);
+ await expectPromptsDrawnByPlugin(page);
+ await expectTimelinePromptNotMounted(page, agent.prompts[39]);
+
+ // Unloaded, then loaded, then near the newest edge.
+ await clickChatOutlineRowEdge(page, 40);
+ await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]);
+ await attachPromptTimelineEvidence(page, test.info());
+ await clickChatOutlineRowEdge(page, 45);
+ await expectTimelinePromptLandedBelowTop(page, agent.prompts[44]);
+ await clickChatOutlineRowEdge(page, 78);
+ await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.prompts[77]);
+ await expectPromptsDrawnByPlugin(page);
+ });
+ } finally {
+ await agent.cleanup();
+ }
+ });
+
+ test("jumps to a loaded virtualized prompt a timeline plugin draws without fetching", async ({
+ page,
+ }) => {
+ test.setTimeout(180_000);
+ const agent = await seedLongMockAgentTimeline({ turns: 80 });
+ const prompt = agent.prompts[29]!;
+ try {
+ await withPromptTimelinePlugin(async () => {
+ const history = await holdOlderHistoryPages(page, agent);
+ await page.setViewportSize(WIDE_VIEWPORT);
+ await openAgentTimeline(page, agent);
+ await expectChatOutlinePrompts(page, 80);
+ await scrollThroughOlderHistoryPages(page, 2, history);
+ await scrollTimelineToNewestLoadedEdge(page);
+ await expectTimelinePromptNotMounted(page, prompt);
+ const requestsBeforeJump = history.requestCount();
+
+ await clickChatOutlineRowEdge(page, 30);
+ await expectTimelinePromptLandedBelowTop(page, prompt);
+ await expectVirtualizedPluginPromptVisible(page, prompt);
+ await attachPromptTimelineEvidence(page, test.info());
+ expect(history.requestCount()).toBe(requestsBeforeJump);
+ }, [prompt]);
+ } finally {
+ await agent.cleanup();
+ }
+ });
+
test.describe("with the whole conversation loaded", () => {
let agent: LongTimelineAgent;
diff --git a/packages/app/e2e/browser/compact-workspace-header.spec.ts b/packages/app/e2e/browser/compact-workspace-header.spec.ts
new file mode 100644
index 0000000000..be26dbb8d1
--- /dev/null
+++ b/packages/app/e2e/browser/compact-workspace-header.spec.ts
@@ -0,0 +1,192 @@
+import type { Page, TestInfo } from "@playwright/test";
+import { test, expect } from "../support/fixtures";
+import { seedWorkspace } from "../support/helpers/seed-client";
+import { openAgentRoute } from "../support/helpers/mock-agent";
+import { getServerId } from "../support/helpers/server-id";
+import { expectOpenTabCount } from "../support/helpers/sheet";
+import {
+ closeCompactWorkspaceMenu,
+ goBackInMenu,
+ menuRow,
+ openCompactWorkspaceMenu,
+ openRunningScriptPage,
+ openScriptsPage,
+ runScriptFromScriptsPage,
+ scriptRow,
+ scriptsMenuRow,
+} from "../support/helpers/compact-workspace-scripts";
+
+const labels = {
+ long: {
+ title: "workspace-with-a-very-long-release-and-investigation-name",
+ project: "project-with-a-very-long-customer-integration-name",
+ },
+ short: { title: "main", project: "Paseo" },
+};
+
+async function openNamedWorkspace(page: Page, names: typeof labels.long) {
+ const workspace = await seedWorkspace({ repoPrefix: "header-width-", title: names.title });
+ try {
+ await workspace.client.renameProject(workspace.projectId, names.project);
+ const agent = await workspace.client.createAgent({
+ provider: "mock",
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Header layout check",
+ modeId: "load-test",
+ model: "e2e-fast-stream",
+ });
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
+ await expect(page.getByTestId("workspace-header-title")).toHaveText(names.title);
+ await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(names.project);
+ await expect(
+ page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true }),
+ ).toHaveCount(1);
+ return workspace;
+ } catch (error) {
+ await workspace.cleanup();
+ throw error;
+ }
+}
+
+async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) {
+ const menu = page.getByTestId("menu-button").filter({ visible: true });
+ const title = page.getByTestId("workspace-header-title");
+ const project = page.getByTestId("workspace-header-subtitle");
+ const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true });
+ const trigger = page.getByRole("button", { name: /Switch tabs/ });
+ const measure = async () => ({
+ menu: await menu.boundingBox(),
+ title: await title.boundingBox(),
+ project: await project.boundingBox(),
+ badge: await badge.boundingBox(),
+ icon: await badge.locator("svg").boundingBox(),
+ trigger: await trigger.boundingBox(),
+ titleTruncated: await title.evaluate((el) => el.scrollWidth > el.clientWidth),
+ projectTruncated: await project.evaluate((el) => el.scrollWidth > el.clientWidth),
+ });
+ await page.screenshot({ path: info.outputPath("header.png") });
+ await expect(async () => {
+ const bounds = await measure();
+ expect(bounds.menu).not.toBeNull();
+ expect(bounds.title).not.toBeNull();
+ expect(bounds.project).not.toBeNull();
+ expect(bounds.badge).not.toBeNull();
+ expect(bounds.icon).not.toBeNull();
+ expect(bounds.trigger).not.toBeNull();
+ // The labels sit between the menu button and the actions without overlapping either.
+ expect(bounds.title!.x).toBeGreaterThan(bounds.menu!.x + bounds.menu!.width);
+ expect(bounds.project!.x).toBe(bounds.title!.x);
+ for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) {
+ expect(box.x + box.width).toBeLessThan(bounds.trigger!.x);
+ }
+ expect(bounds.project!.width).toBeGreaterThan(20);
+ // Long labels truncate instead of squeezing the host icon.
+ expect(bounds.icon!.width).toBe(bounds.icon!.height);
+ expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width);
+ expect(bounds.titleTruncated).toBe(long);
+ expect(bounds.projectTruncated).toBe(long);
+ }).toPass({ timeout: 5_000 });
+ await info.attach("header-bounds", {
+ body: JSON.stringify(await measure(), null, 2),
+ contentType: "application/json",
+ });
+}
+
+for (const width of [320, 390]) {
+ for (const kind of ["long", "short"] as const) {
+ test(`compact header contains ${kind} labels at ${width}px`, async ({ page }, info) => {
+ await page.setViewportSize({ width, height: 844 });
+ const workspace = await openNamedWorkspace(page, labels[kind]);
+ try {
+ await expectHeaderContentsFit(page, info, kind === "long");
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+ }
+}
+
+const KEEP_ALIVE = 'node -e "setInterval(() => {}, 1000)"';
+const WEB_SERVER =
+ "node -e \"require('http').createServer((q, r) => r.end('ok')).listen(process.env.PASEO_PORT)\"";
+
+async function openWorkspaceWithScripts(page: Page) {
+ const workspace = await seedWorkspace({
+ repoPrefix: "compact-scripts-",
+ repo: {
+ paseoConfig: {
+ scripts: {
+ lint: { type: "script", command: KEEP_ALIVE },
+ web: { type: "service", command: WEB_SERVER },
+ },
+ },
+ },
+ });
+ try {
+ const agent = await workspace.client.createAgent({
+ provider: "mock",
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Scripts menu check",
+ modeId: "load-test",
+ model: "e2e-fast-stream",
+ });
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
+ return workspace;
+ } catch (error) {
+ await workspace.cleanup();
+ throw error;
+ }
+}
+
+test("compact header menu runs, shows, and stops workspace scripts", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const workspace = await openWorkspaceWithScripts(page);
+ try {
+ await expectOpenTabCount(page, 1);
+
+ await test.step("nothing running: the Scripts row has no value", async () => {
+ await openCompactWorkspaceMenu(page);
+ await expect(scriptsMenuRow(page)).toHaveAccessibleName("Scripts");
+ await closeCompactWorkspaceMenu(page);
+ });
+
+ await test.step("a stopped script runs from the Scripts page into a terminal tab", async () => {
+ await runScriptFromScriptsPage(page, "lint");
+ await expectOpenTabCount(page, 2);
+ await openCompactWorkspaceMenu(page);
+ await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/);
+ await closeCompactWorkspaceMenu(page);
+ });
+
+ await test.step("two running scripts are counted on the root row", async () => {
+ await runScriptFromScriptsPage(page, "web");
+ await expectOpenTabCount(page, 3);
+ await openCompactWorkspaceMenu(page);
+ await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*2 running$/);
+ await closeCompactWorkspaceMenu(page);
+ });
+
+ await test.step("stopping a script keeps its page open in the stopped state", async () => {
+ await openRunningScriptPage(page, "web");
+ await expect(menuRow(page, "View service")).toBeVisible();
+ await menuRow(page, "Stop").click();
+ await expect(menuRow(page, "Run")).toBeVisible({ timeout: 15_000 });
+ await expect(menuRow(page, "Stop")).toHaveCount(0);
+
+ await goBackInMenu(page);
+ await expect(scriptRow(page, "web")).toHaveAccessibleName(/^web\s*exit \d+$/);
+ await expect(scriptRow(page, "lint")).toHaveAccessibleName(/^lint\s*Running$/);
+ await closeCompactWorkspaceMenu(page);
+ });
+
+ await test.step("the root row names the one script still running", async () => {
+ await openScriptsPage(page);
+ await goBackInMenu(page);
+ await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/);
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts
new file mode 100644
index 0000000000..77a2a0882b
--- /dev/null
+++ b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts
@@ -0,0 +1,154 @@
+import { readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+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 {
+ expectSubagentRowVisible,
+ openSubagentFromCompactTrack,
+ seedSubagent,
+} from "../support/helpers/subagents";
+import {
+ closeSheet,
+ expectOpenTabCount,
+ openSheetAsTab,
+ expectDeclinedDiscardPrompt,
+ expectSheetAtSnap,
+ replaceSheetFileText,
+ selectTabFromSwitcher,
+ sheet,
+ sheetHeader,
+ tapSheetBackdrop,
+} from "../support/helpers/sheet";
+
+const FILE_LINK = "linked.ts:3";
+
+function promptMentioning(target: string): string {
+ return [
+ "Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
+ "Return JSON only with fields 'title' and 'branch'.",
+ "",
+ "",
+ `Open \`${target}\` now`,
+ " ",
+ ].join("\n");
+}
+
+test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const workspace = await seedMockAgentWorkspace({
+ repoPrefix: "compact-workspace-sheet-",
+ title: "Sheet parent",
+ initialPrompt: promptMentioning(FILE_LINK),
+ });
+ try {
+ const gate = await installDaemonWebSocketGate(page);
+ const linkedPath = path.join(workspace.cwd, "linked.ts");
+ await writeFile(linkedPath, "one\ntwo\nthree\n", "utf8");
+ const child = await seedSubagent(
+ { client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
+ { parentId: workspace.agentId, title: "Sheet child" },
+ );
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
+ await expectOpenTabCount(page, 1);
+
+ await test.step("a chat file link opens the file in a sheet", async () => {
+ const fileLink = page.getByText(FILE_LINK, { exact: true });
+ await expect(fileLink).toBeVisible({ timeout: 15_000 });
+ await fileLink.click();
+
+ 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 closeSheet(page, "linked.ts");
+ });
+
+ await test.step("a subagent opens in a sheet over the subagents list", async () => {
+ await openSubagentFromCompactTrack(page, child.id);
+
+ 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("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("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 fileSheet = sheet(page, "linked.ts");
+ await expect(fileSheet).toBeVisible();
+ // Hold the autosave so the edit stays unsaved.
+ gate.holdNextClientRequest("fs.file.write.request");
+ await replaceSheetFileText(page, "linked.ts", "edited in the sheet");
+ await gate.waitForHeldClientRequest();
+
+ await expectDeclinedDiscardPrompt(page, () => tapSheetBackdrop(page));
+ await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
+ await expectDeclinedDiscardPrompt(page, () =>
+ sheetHeader(page, "linked.ts").getByRole("button", { name: "Close" }).click(),
+ );
+ await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
+
+ gate.releaseHeldClientRequest();
+ await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts
index d30e69b18c..d83046d8b2 100644
--- a/packages/app/e2e/browser/composer-autocomplete.spec.ts
+++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts
@@ -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 {
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();
}
diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts
index 455c58180a..b3d1b56c12 100644
--- a/packages/app/e2e/browser/composer-diff-stat.spec.ts
+++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts
@@ -1,7 +1,14 @@
+import type { Locator } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
+import {
+ fillComposerDraft,
+ expectComposerDraft,
+ attachFileFromMenu,
+} from "../support/helpers/composer";
import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -26,14 +33,19 @@ async function openComposerDiff(page: Page) {
await composerChangesPill(page).click();
}
-async function seedChangedAgent(repoPrefix: string) {
+async function seedChangedAgent(
+ repoPrefix: string,
+ featureValues?: Record,
+ files: Array<{ path: string; content: string }> = [],
+) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
+ featureValues,
title: "Composer diff stat",
repo: {
withRemote: true,
// Exclude the local remote before the daemon starts observing this checkout.
- files: [{ path: ".gitignore", content: "/remote.git/\n" }],
+ files: [{ path: ".gitignore", content: "/remote.git/\n" }, ...files],
},
});
try {
@@ -156,3 +168,690 @@ test("composer diff stat reveals Changes, then opens the diff in the focused pan
await workspace.cleanup();
}
});
+
+async function tapFirstAddedDiffLine(
+ page: Page,
+ surface: Locator = page.getByTestId("working-diff-panel"),
+) {
+ const body = surface.getByTestId("diff-file-0-body");
+ await expect(body).toBeVisible({ timeout: 30000 });
+ const bounds = await body.boundingBox();
+ if (!bounds) throw new Error("Expanded diff body has no bounds");
+ const fontSize = await surface
+ .getByTestId("git-diff-canvas")
+ .evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
+ await page.touchscreen.tap(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5);
+}
+
+async function openReviewDiff(page: Page, workspace: Awaited>) {
+ await page.setViewportSize({ width: 1400, height: 900 });
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
+ await expect(composerChangesPill(page)).toBeVisible({ timeout: 30000 });
+ if (await page.getByTestId("workspace-tab-working_diff").count()) {
+ await selectDiffTab(page);
+ } else {
+ await revealComposerChangesInExplorer(page);
+ await openComposerDiff(page);
+ }
+ await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
+}
+
+async function saveReviewComment(page: Page, body: string) {
+ await page.getByRole("textbox", { name: "Review comment" }).fill(body);
+ await page.getByRole("button", { name: "Save review comment", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+}
+
+async function cancelReviewComment(page: Page) {
+ await page.getByRole("button", { name: "Cancel review comment", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+}
+
+test.describe("review comments", () => {
+ test.use({ hasTouch: true });
+
+ test("compact review comments open a sheet and preserve saved comments through save, edit, cancel and reload", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("compact-review-comment-");
+ try {
+ await test.step("Open a compact sheet with reachable input and actions", async () => {
+ await openReviewDiff(page, workspace);
+ await showCompactLayout(page);
+ await tapFirstAddedDiffLine(page);
+ await expectCompactCommentSheet(page);
+ });
+ await test.step("Save, discard an edit, then save an updated comment", async () => {
+ await saveReviewComment(page, " Please simplify this. ");
+ await expectSavedReview(page, "Please simplify this.");
+ await editSavedReview(page, "Please simplify this.");
+ await fillReviewComment(page, "Discard this edit");
+ await cancelReviewComment(page);
+ await expectSavedReview(page, "Please simplify this.");
+ await editSavedReview(page, "Please simplify this.");
+ await saveReviewComment(page, "Updated comment");
+ await expectSavedReview(page, "Updated comment");
+ });
+ await test.step("Discard a new comment and retain the saved one after reload", async () => {
+ await tapFirstAddedDiffLine(page);
+ await fillReviewComment(page, "Discard new comment");
+ await cancelReviewComment(page);
+ await expectReviewAbsent(page, "Discard new comment");
+ await reloadReview(page);
+ await expectSavedReview(page, "Updated comment");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("wide review comments retain inline editing", async ({ page }) => {
+ const workspace = await seedChangedAgent("wide-review-comment-");
+ try {
+ await test.step("Save and cancel an inline edit on a wide screen", async () => {
+ await openReviewDiff(page, workspace);
+ await tapFirstAddedDiffLine(page);
+ await expectInlineCommentEditor(page);
+ await saveReviewComment(page, "Wide comment");
+ await expectSavedReview(page, "Wide comment");
+ await editSavedReview(page, "Wide comment");
+ await expectInlineCommentEditor(page);
+ await cancelReviewComment(page);
+ await expectSavedReview(page, "Wide comment");
+ });
+ await test.step("The existing composer still sends the review", async () => {
+ await sendReviewThroughComposer(page, workspace.agentId);
+ await selectDiffTab(page);
+ await expectReviewAbsent(page, "Wide comment");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+});
+
+async function sendReviewThroughComposer(page: Page, agentId: string) {
+ await page.getByTestId(`workspace-tab-agent_${agentId}`).click();
+ await expect(page.getByTestId("composer-review-attachment-pill")).toBeVisible();
+ await page.getByRole("textbox", { name: "Message agent..." }).fill("Address this review");
+ await page.getByRole("button", { name: "Send message", exact: true }).click();
+ await expect(page.getByText("Address this review", { exact: true }).first()).toBeVisible();
+ await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
+}
+
+async function addSavedReview(
+ page: Page,
+ body: string,
+ surface: Locator = page.getByTestId("working-diff-panel"),
+) {
+ await tapFirstAddedDiffLine(page, surface);
+ await saveReviewComment(page, body);
+ await expectSavedReview(page, body, surface);
+}
+
+async function expectSavedReview(
+ page: Page,
+ body: string,
+ surface: Locator = page.getByTestId("working-diff-panel"),
+) {
+ await expect(surface.getByText(body, { exact: true })).toBeVisible();
+}
+
+async function editSavedReview(page: Page, expectedBody: string) {
+ await page
+ .getByTestId("working-diff-panel")
+ .getByRole("button", { name: "Edit review comment", exact: true })
+ .click();
+ await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(expectedBody);
+}
+
+async function sendFeedback(page: Page, count: number) {
+ await page.getByRole("button", { name: `Send feedback (${count})`, exact: true }).click();
+}
+
+async function expectFeedbackSent(page: Page, recipient: string) {
+ await expect(page.getByTestId("app-toast")).toHaveText(`Feedback sent to ${recipient}`);
+ await expect(page.getByTestId("working-diff-panel")).toBeVisible();
+}
+
+test.describe("send review feedback", () => {
+ test.use({ hasTouch: true });
+
+ test("compact feedback sends directly to the only workspace agent and preserves its composer draft", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("review-feedback-single-");
+ try {
+ await test.step("Save a review without changing the chat draft", async () => {
+ await openReviewDiff(page, workspace);
+ await selectAgentTab(page, workspace.agentId);
+ await fillComposerDraft(page, "Keep this thought");
+ await attachFileFromMenu(page, {
+ name: "unrelated.txt",
+ mimeType: "text/plain",
+ buffer: Buffer.from("Unrelated draft attachment"),
+ });
+ await selectDiffTab(page);
+ await showCompactLayout(page);
+ await addSavedReview(page, "Please simplify this.");
+ });
+ await test.step("Send directly and stay on the diff", async () => {
+ await sendFeedback(page, 1);
+ await expectFeedbackSent(page, "Composer diff stat");
+ await expectReviewAbsent(page, "Please simplify this.");
+ await captureFeedback(page, "single");
+ await expect(
+ page.getByText("Feedback sent to Composer diff stat", { exact: true }),
+ ).toHaveCount(0);
+ });
+ await test.step("Return to the untouched composer draft", async () => {
+ await showWideLayout(page);
+ await selectAgentTab(page, workspace.agentId);
+ await expectComposerDraft(page, "Keep this thought");
+ await expect(page.getByText("unrelated.txt", { exact: true })).toBeVisible();
+ await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+ test("feedback pending ownership follows the draft across Diff and compact Changes", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("review-shared-pending-");
+ const gate = await gateNextAgentMessage(page);
+ try {
+ await test.step("Send feedback from the Diff tab and hold its real request", async () => {
+ await openReviewDiff(page, workspace);
+ await showCompactLayout(page);
+ await addSavedReview(page, "One draft across surfaces");
+ await sendFeedback(page, 1);
+ await gate.waitForRequest();
+ });
+ await test.step("The compact Changes surface observes the same pending operation", async () => {
+ await revealCompactChanges(page);
+ await expect(
+ compactReviewSurface(page).getByRole("button", {
+ name: "Sending feedback (1)",
+ exact: true,
+ }),
+ ).toBeDisabled();
+ expect(gate.requestCount()).toBe(1);
+ gate.accept();
+ await expectFeedbackSent(page, "Composer diff stat");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("an unsaved review survives presentation changes and acknowledgement before Save", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("review-editor-ownership-");
+ const gate = await gateNextAgentMessage(page);
+ try {
+ await test.step("Carry unsaved inline text into the compact sheet and back", async () => {
+ await openReviewDiff(page, workspace);
+ await tapFirstAddedDiffLine(page);
+ await fillReviewComment(page, "Body across presentations");
+ await showCompactLayout(page);
+ await expectReviewEditorBody(page, "Body across presentations");
+ await showWideLayout(page);
+ await expectReviewEditorBody(page, "Body across presentations");
+ await saveReviewComment(page, "Body across presentations");
+ });
+ await test.step("Keep an open edit saveable after acknowledgement removes its original", async () => {
+ await sendFeedback(page, 1);
+ await gate.waitForRequest();
+ await editSavedReview(page, "Body across presentations");
+ await fillReviewComment(page, "Keep this unsaved edit");
+ gate.accept();
+ await expectFeedbackSent(page, "Composer diff stat");
+ await expectReviewEditorBody(page, "Keep this unsaved edit");
+ await showCompactLayout(page);
+ await expectReviewEditorBody(page, "Keep this unsaved edit");
+ await saveReviewComment(page, "Keep this unsaved edit");
+ await expectSavedReview(page, "Keep this unsaved edit");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("saved feedback preserves navigation across multiple diff files", async ({ page }) => {
+ const workspace = await seedMultiFileReview();
+ try {
+ await test.step("Save compact feedback and retain both floating actions", async () => {
+ await openCompactReviewExplorer(page, workspace);
+ await addSavedReview(page, "Keep file navigation available", compactReviewSurface(page));
+ await expectReviewNavigationReachable(page);
+ });
+ await test.step("Jump to another file without covering the feedback action", async () => {
+ await jumpToReviewFile(page, "z-last.ts");
+ await scrollReviewToLastLine(page);
+ await expectReviewNavigationReachable(page);
+ await captureFeedback(page, "navigation-compact");
+ });
+ await test.step("Wide file navigation and feedback remain reachable", async () => {
+ await openReviewDiff(page, workspace);
+ await navigateWideReviewFile(page, "README.md");
+ await expectFeedbackReachable(page);
+ await expectSavedReview(page, "Keep file navigation available");
+ await captureFeedback(page, "navigation-wide");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("feedback sends only matching comments and preserves orphaned comments", async ({
+ page,
+ }) => {
+ const workspace = await seedMultiFileReview();
+ try {
+ await test.step("A saved comment whose diff disappears has no sendable context", async () => {
+ await openReviewDiff(page, workspace);
+ await showCompactLayout(page);
+ await addSavedReview(page, "Retain this orphan");
+ await setReadmeDiff(workspace, false);
+ await expectUnmatchedFeedback(page);
+ });
+ await test.step("Send the matching subset with an accurate count", async () => {
+ await addSavedReview(page, "Send this matching comment");
+ await sendFeedback(page, 1);
+ await expectFeedbackSent(page, "Composer diff stat");
+ await expectReviewAbsent(page, "Send this matching comment");
+ await expectUnmatchedFeedback(page);
+ });
+ await test.step("Restoring the missing diff reveals the unsent comment", async () => {
+ await setReadmeDiff(workspace, true);
+ await expectSavedReview(page, "Retain this orphan");
+ await expectFeedbackReachable(page);
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("zero open agent tabs retain feedback with a disabled reason", async ({ page }) => {
+ const workspace = await seedChangedAgent("review-feedback-zero-");
+ try {
+ await test.step("Close the agent tab while keeping the review", async () => {
+ await openReviewDiff(page, workspace);
+ await addSavedReview(page, "Keep until an agent is opened");
+ await closeAgentTab(page, workspace.agentId);
+ });
+ await test.step("The disabled action explains why in wide and compact layouts", async () => {
+ await expectFeedbackUnavailable(page);
+ await captureFeedback(page, "zero-wide");
+ await showCompactLayout(page);
+ await expectFeedbackUnavailable(page);
+ await expectSavedReview(page, "Keep until an agent is opened");
+ await captureFeedback(page, "zero-compact");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("review-feedback-multiple-");
+ const other = await seedChangedAgent("review-feedback-other-");
+ try {
+ const second = await createReviewRecipient(workspace, "Second reviewer");
+ const historical = await createReviewRecipient(workspace, "Closed historical agent");
+ await test.step("Open two local tabs and a tab in another workspace", async () => {
+ await openReviewDiff(page, other);
+ await addSavedReview(page, "Other workspace review");
+ await openReviewDiff(page, workspace);
+ await closeAgentTab(page, historical.id);
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: second.id });
+ await selectDiffTab(page);
+ await addSavedReview(page, "Review for second agent");
+ });
+ await test.step("The wide menu includes only local open agents", async () => {
+ await captureWideFeedbackAppearance(page);
+ });
+ await test.step("Escape returns keyboard focus to feedback so the chooser can reopen", async () => {
+ await openFeedbackWithKeyboard(page);
+ await dismissRecipientMenu(page);
+ await expectFeedbackFocused(page);
+ await reopenFeedbackWithKeyboard(page);
+ await dismissRecipientMenu(page);
+ await expectFeedbackFocused(page);
+ });
+ await test.step("Choose the second agent in the compact menu and stay on the diff", async () => {
+ await showCompactLayout(page);
+ await captureFeedback(page, "cta-compact");
+ await sendFeedback(page, 1);
+ await expectRecipientMenu(page);
+ await captureFeedback(page, "menu-compact");
+ await chooseFeedbackRecipient(page, "Second reviewer");
+ await expectFeedbackSent(page, "Second reviewer");
+ await expectReviewAbsent(page, "Review for second agent");
+ });
+ await test.step("The other workspace's saved review remains", async () => {
+ await openReviewDiff(page, other);
+ await expectSavedReview(page, "Other workspace review");
+ });
+ } finally {
+ await workspace.cleanup();
+ await other.cleanup();
+ }
+ });
+
+ test("failed feedback remains editable and retries through the real provider", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("review-feedback-retry-", { mockPromptRejections: 1 });
+ try {
+ await test.step("A provider rejection retains the saved review", async () => {
+ await openReviewDiff(page, workspace);
+ await showCompactLayout(page);
+ await addSavedReview(page, "Initial feedback");
+ await sendFeedback(page, 1);
+ await expectFeedbackFailure(page);
+ await expectSavedReview(page, "Initial feedback");
+ await captureFeedback(page, "failure");
+ });
+ await test.step("Edit the retained feedback and retry successfully", async () => {
+ await editSavedReview(page, "Initial feedback");
+ await saveReviewComment(page, "Corrected feedback");
+ await sendFeedback(page, 1);
+ await expectFeedbackSent(page, "Composer diff stat");
+ await expectReviewAbsent(page, "Corrected feedback");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("pending feedback blocks repeat submission and preserves edits and additions", async ({
+ page,
+ }) => {
+ const workspace = await seedChangedAgent("review-feedback-pending-");
+ const gate = await gateNextAgentMessage(page);
+ try {
+ await test.step("Hold the real request while feedback is pending", async () => {
+ await openReviewDiff(page, workspace);
+ await showCompactLayout(page);
+ await addSavedReview(page, "Sent version");
+ await sendFeedback(page, 1);
+ const request = await gate.waitForRequest();
+ expect(request.agentId).toBe(workspace.agentId);
+ expect(request.text).toBe("Please address this code review.");
+ expect(request.attachments).toMatchObject([
+ {
+ type: "review",
+ cwd: workspace.cwd,
+ comments: [
+ {
+ filePath: "README.md",
+ lineNumber: 2,
+ body: "Sent version",
+ context: { targetLine: { content: "export const one = 1;", newLineNumber: 2 } },
+ },
+ ],
+ },
+ ]);
+ await expectFeedbackPending(page);
+ });
+ await test.step("Save an edit and another comment before acknowledgement", async () => {
+ await editSavedReview(page, "Sent version");
+ await saveReviewComment(page, "Edited during send");
+ await addSavedReview(page, "Added during send");
+ await expectFeedbackPending(page);
+ expect(gate.requestCount()).toBe(1);
+ await captureFeedback(page, "pending");
+ });
+ await test.step("A real acknowledgement preserves both new versions", async () => {
+ gate.accept();
+ await expectFeedbackSent(page, "Composer diff stat");
+ await expectSavedReview(page, "Edited during send");
+ await expectSavedReview(page, "Added during send");
+ await expectReviewAbsent(page, "Sent version");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+});
+
+async function showCompactLayout(page: Page) {
+ await page.setViewportSize({ width: 390, height: 844 });
+}
+async function showWideLayout(page: Page) {
+ await page.setViewportSize({ width: 1400, height: 900 });
+}
+async function selectAgentTab(page: Page, agentId: string) {
+ await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ position: { x: 12, y: 13 } });
+}
+async function selectDiffTab(page: Page) {
+ await page.getByTestId("workspace-tab-working_diff").click({ position: { x: 12, y: 13 } });
+}
+async function fillReviewComment(page: Page, body: string) {
+ await page.getByRole("textbox", { name: "Review comment" }).fill(body);
+}
+async function expectReviewAbsent(page: Page, body: string) {
+ await expect(page.getByTestId("working-diff-panel").getByText(body, { exact: true })).toHaveCount(
+ 0,
+ );
+}
+async function reloadReview(page: Page) {
+ await page.reload();
+ await expect(page.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30000 });
+}
+async function expectInlineCommentEditor(page: Page) {
+ await expect(page.getByTestId("inline-review-editor")).toBeVisible();
+ await expect(page.getByTestId("review-comment-sheet")).toHaveCount(0);
+}
+async function expectCompactCommentSheet(page: Page) {
+ await expect(page.getByTestId("review-comment-sheet")).toBeVisible();
+ await expect(page.getByText("README.md · +2", { exact: true })).toBeVisible();
+ await expect(page.getByRole("textbox", { name: "Review comment" })).toBeInViewport({ ratio: 1 });
+ for (const action of ["Save", "Cancel"])
+ await expect(
+ page.getByRole("button", { name: `${action} review comment`, exact: true }),
+ ).toBeInViewport({ ratio: 1 });
+ await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "Save review comment", exact: true }),
+ ).toBeDisabled();
+}
+function feedbackButton(page: Page) {
+ return page.getByRole("button", { name: "Send feedback (1)", exact: true });
+}
+async function openFeedbackWithKeyboard(page: Page) {
+ await feedbackButton(page).focus();
+ await expectFeedbackFocused(page);
+ await reopenFeedbackWithKeyboard(page);
+}
+async function reopenFeedbackWithKeyboard(page: Page) {
+ await page.keyboard.press("Enter");
+ await expectRecipientMenu(page);
+ const recipient = page.getByRole("menuitem", { name: "Composer diff stat", exact: true });
+ await recipient.focus();
+ await expect(recipient).toBeFocused();
+}
+async function expectFeedbackFocused(page: Page) {
+ await expect(feedbackButton(page)).toBeFocused();
+}
+
+async function captureWideFeedbackAppearance(page: Page) {
+ for (const colorScheme of ["light", "dark"] as const) {
+ await page.emulateMedia({ colorScheme });
+ await expectSavedReview(page, "Review for second agent");
+ await captureFeedback(page, `cta-wide-${colorScheme}-closed`);
+ await sendFeedback(page, 1);
+ await expectRecipientMenu(page);
+ await captureFeedback(page, `cta-wide-${colorScheme}-open`);
+ await dismissRecipientMenu(page);
+ }
+ await page.emulateMedia({ colorScheme: "light" });
+}
+
+async function captureFeedback(page: Page, name: string) {
+ await page.screenshot({ path: `/tmp/phase2-feedback-${name}.png` });
+}
+
+async function closeAgentTab(page: Page, agentId: string) {
+ await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ button: "right" });
+ await page.getByTestId(`workspace-tab-context-agent_${agentId}-close`).click();
+ await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0);
+}
+async function expectFeedbackUnavailable(page: Page) {
+ await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeDisabled();
+ await expect(
+ page.getByText("Open an agent tab in this workspace to send feedback.", { exact: true }),
+ ).toBeVisible();
+}
+async function createReviewRecipient(
+ workspace: Awaited>,
+ title: string,
+) {
+ return workspace.client.createAgent({
+ provider: "mock",
+ cwd: workspace.cwd,
+ workspaceId: workspace.workspaceId,
+ title,
+ modeId: "load-test",
+ model: "e2e-fast-stream",
+ });
+}
+async function expectRecipientMenu(page: Page) {
+ for (const name of ["Composer diff stat", "Second reviewer"]) {
+ await expect(page.getByRole("menuitem", { name, exact: true })).toBeInViewport({ ratio: 1 });
+ }
+ await expect(page.getByRole("menuitem")).toHaveCount(2);
+}
+async function dismissRecipientMenu(page: Page) {
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("menuitem")).toHaveCount(0);
+}
+async function chooseFeedbackRecipient(page: Page, name: string) {
+ await page.getByRole("menuitem", { name, exact: true }).click();
+}
+async function expectFeedbackFailure(page: Page) {
+ await expect(
+ page.getByText("Requested mock prompt rejection", { exact: true }).first(),
+ ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeEnabled();
+}
+async function expectFeedbackPending(page: Page, count = 1) {
+ await expect(
+ page.getByRole("button", { name: `Sending feedback (${count})`, exact: true }),
+ ).toBeDisabled();
+}
+
+async function seedMultiFileReview() {
+ const workspace = await seedChangedAgent("review-feedback-navigation-", undefined, [
+ { path: "z-last.ts", content: "export const final = 0;\n" },
+ ]);
+ await writeFile(
+ path.join(workspace.cwd, "z-last.ts"),
+ Array.from({ length: 120 }, (_, index) => `export const value${index} = ${index};`).join("\n") +
+ "\n",
+ );
+ await workspace.client.checkoutRefresh(workspace.cwd);
+ return workspace;
+}
+async function expectFeedbackReachable(page: Page) {
+ await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeInViewport(
+ { ratio: 1 },
+ );
+}
+async function expectReviewNavigationReachable(page: Page) {
+ await expectFeedbackReachable(page);
+ const jump = page.getByRole("button", { name: "Jump to file", exact: true });
+ await expect(jump).toBeInViewport({ ratio: 1 });
+ const navigationBounds = await jump.boundingBox();
+ const feedbackBounds = await page
+ .getByRole("button", { name: "Send feedback (1)", exact: true })
+ .boundingBox();
+ if (!navigationBounds || !feedbackBounds) throw new Error("Review actions have no bounds");
+ expect(feedbackBounds.y + feedbackBounds.height).toBeLessThanOrEqual(navigationBounds.y);
+}
+async function jumpToReviewFile(page: Page, name: string) {
+ await page.getByRole("button", { name: "Jump to file", exact: true }).click();
+ const sheet = page.getByTestId("changes-jump-to-file-sheet");
+ await page
+ .getByTestId("changes-file-tree")
+ .filter({ visible: true })
+ .getByText(name, { exact: true })
+ .click();
+ await expect(sheet).toHaveCount(0);
+ await expect(reviewFileHeader(compactReviewSurface(page), name)).toBeInViewport();
+}
+async function navigateWideReviewFile(page: Page, name: string) {
+ const explorer = await ensureExplorerSidebar(page);
+ await explorer.getByTestId("changes-tree-panel").getByText(name, { exact: true }).click();
+ await expect(reviewFileHeader(page.getByTestId("working-diff-panel"), name)).toBeInViewport();
+}
+
+async function setReadmeDiff(
+ workspace: Awaited>,
+ changed: boolean,
+) {
+ await writeFile(
+ path.join(workspace.cwd, "README.md"),
+ changed ? "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n" : "# Temp Repo\n",
+ );
+ await workspace.client.checkoutRefresh(workspace.cwd);
+}
+async function expectUnmatchedFeedback(page: Page) {
+ await expect(page.getByRole("button", { name: "Send feedback (0)", exact: true })).toBeDisabled();
+ await expect(
+ page.getByText("Saved comments no longer match this diff.", { exact: true }),
+ ).toBeVisible();
+}
+
+function compactReviewSurface(page: Page) {
+ return page.getByTestId("explorer-content-area").filter({ visible: true });
+}
+async function openCompactReviewExplorer(
+ page: Page,
+ workspace: Awaited>,
+) {
+ await showWideLayout(page);
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
+ await selectAgentTab(page, workspace.agentId);
+ await showCompactLayout(page);
+ await composerChangesPill(page).click();
+ await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
+}
+
+function reviewFileHeader(surface: Locator, name: string) {
+ return surface.locator(`[data-diff-header-path="${name}"]`).getByTestId(/^diff-file-\d+$/);
+}
+
+async function scrollReviewToLastLine(page: Page) {
+ const surface = compactReviewSurface(page);
+ const bounds = await surface.boundingBox();
+ if (!bounds) throw new Error("Compact review has no bounds");
+ await page.mouse.move(bounds.x + 120, bounds.y + 200);
+ await page.mouse.wheel(0, 10000);
+ await expect
+ .poll(async () => {
+ const body = await surface.getByTestId("diff-file-1-body").boundingBox();
+ const feedback = await page
+ .getByRole("button", { name: "Send feedback (1)", exact: true })
+ .boundingBox();
+ if (!body || !feedback) return false;
+ return body.y + body.height <= feedback.y;
+ })
+ .toBe(true);
+}
+
+async function revealCompactChanges(page: Page) {
+ await page.getByTestId("workspace-explorer-toggle").first().click();
+ await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click();
+ await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
+}
+async function expectReviewEditorBody(page: Page, body: string) {
+ await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue(
+ body,
+ );
+}
diff --git a/packages/app/e2e/browser/composer-email-mentions.spec.ts b/packages/app/e2e/browser/composer-email-mentions.spec.ts
new file mode 100644
index 0000000000..283fca26ca
--- /dev/null
+++ b/packages/app/e2e/browser/composer-email-mentions.spec.ts
@@ -0,0 +1,104 @@
+import { expect, test, type Page } from "../support/fixtures";
+import {
+ composerLocator,
+ expectComposerDraft,
+ expectComposerVisible,
+ fillComposerDraft,
+ enterComposerFullscreen,
+ exitComposerFullscreen,
+ resizeComposerViewport,
+ selectComposerDraftRange,
+} from "../support/helpers/composer";
+import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+
+// The popover has no semantic role; its rows are React Native Pressables.
+function fileSuggestions(page: Page) {
+ return page.getByTestId("composer-autocomplete-popover");
+}
+
+async function selectReadmeMention(page: Page): Promise {
+ await fillComposerDraft(page, "@README");
+ await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
+ await expect(fileSuggestions(page).getByText("example.com", { exact: true })).toHaveCount(0);
+ await composerLocator(page).press("Enter");
+ await expectComposerDraft(page, '"README.md"');
+}
+
+async function sendEmailIntact(page: Page, email: string): Promise {
+ await fillComposerDraft(page, email);
+ await expect(fileSuggestions(page)).not.toBeVisible();
+ await composerLocator(page).press("Enter");
+ await expectComposerDraft(page, "");
+ await expect(page.getByText(email, { exact: true })).toBeVisible();
+}
+
+for (const email of [
+ "user <12345+user@noreply.example.com>",
+ "alice@example.com",
+ "noreply+@example.com",
+]) {
+ test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-email-",
+ title: "Email mention regression",
+ repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
+ });
+ try {
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await test.step("complete a workspace file mention with Enter", async () => {
+ await selectReadmeMention(page);
+ });
+ await test.step("send an email without offering or inserting workspace files", async () => {
+ await sendEmailIntact(page, email);
+ });
+ } finally {
+ await agent.cleanup();
+ }
+ });
+}
+
+test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
+ page,
+}) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-fullscreen-mention-",
+ title: "Fullscreen mention keys",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ const draft = "First line\nSecond line\n@README";
+ await fillComposerDraft(page, draft);
+ await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
+ await enterComposerFullscreen(page);
+ await expect(fileSuggestions(page)).not.toBeVisible();
+ await selectComposerDraftRange(page, draft.length, draft.length);
+ await composerLocator(page).press("ArrowUp");
+ await expect
+ .poll(() =>
+ composerLocator(page).evaluate((element) => {
+ const input = element as HTMLTextAreaElement;
+ return input.selectionStart === input.selectionEnd && input.selectionStart < 23;
+ }),
+ )
+ .toBe(true);
+ await selectComposerDraftRange(page, draft.length, draft.length);
+ await composerLocator(page).press("Enter");
+ await expectComposerDraft(page, `${draft}\n`);
+ await exitComposerFullscreen(page);
+ await selectReadmeMention(page);
+ const commandDraft = "/cl\nSecond line\nThird line";
+ await fillComposerDraft(page, commandDraft);
+ await selectComposerDraftRange(page, 2, 2);
+ await composerLocator(page).press("ArrowRight");
+ await expect(fileSuggestions(page).getByText("/clear", { exact: true }).first()).toBeVisible();
+ await enterComposerFullscreen(page);
+ await expect(fileSuggestions(page)).not.toBeVisible();
+ await composerLocator(page).press("Enter");
+ await expectComposerDraft(page, "/cl\n\nSecond line\nThird line");
+ } finally {
+ await agent.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts
index b79cfd9255..c81624ff7d 100644
--- a/packages/app/e2e/browser/composer-focus.spec.ts
+++ b/packages/app/e2e/browser/composer-focus.spec.ts
@@ -1,13 +1,127 @@
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 { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
+import { seedWorkspace } from "../support/helpers/seed-client";
+import { switchComposerTabWithKeyboard } from "../support/helpers/composer";
+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({
@@ -56,6 +170,59 @@ test.describe("touch screen wide enough for the desktop layout", () => {
// composer there raises the on-screen keyboard over the conversation.
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
+ test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-touch-remount-",
+ title: "Touch remount",
+ });
+ try {
+ await openAgentRoute(page, agent);
+ await composerLocator(page).fill("Keep this draft through resize");
+ await expectComposerFocused(page);
+ await resizeComposerViewport(page, "portrait");
+ await expectComposerDraft(page, "Keep this draft through resize");
+ await expectComposerNotFocused(page);
+ await composerLocator(page).tap();
+ await expectComposerFocused(page);
+ } finally {
+ await agent.cleanup();
+ }
+ });
+
+ test("returning to an inactive inline composer does not restore focus on touch", async ({
+ page,
+ }) => {
+ const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" });
+ try {
+ const first = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "First touch chat",
+ });
+ const second = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Second touch chat",
+ });
+ await openWorkspaceWithAgents(page, [first, second]);
+ await composerLocator(page).fill("Keep this touch draft");
+ await expectComposerFocused(page);
+ // An external keyboard changes tabs without a pointer click blurring the
+ // editor first. The device still has a touch-primary focus policy.
+ await switchComposerTabWithKeyboard(page, "previous");
+ await expectComposerDraft(page, "");
+ await switchComposerTabWithKeyboard(page, "next");
+ await expectComposerDraft(page, "Keep this touch draft");
+ await expectComposerNotFocused(page);
+ await composerLocator(page).tap();
+ await expectComposerFocused(page);
+ await typeIntoFocusedComposer(page, " after return");
+ await expectComposerDraft(page, "Keep this touch draft after return");
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
test("opening an agent leaves the composer unfocused", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-touch-",
@@ -76,3 +243,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();
+ }
+ });
+}
diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts
index 024446967f..8e1a527f0f 100644
--- a/packages/app/e2e/browser/composer-whitespace.spec.ts
+++ b/packages/app/e2e/browser/composer-whitespace.spec.ts
@@ -1,3 +1,5 @@
+import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
+import { seedWorkspace } from "../support/helpers/seed-client";
import { expect, test, type Page } from "../support/fixtures";
import {
expectNearBottom,
@@ -5,8 +7,38 @@ import {
waitForScrollableChat,
} from "../support/helpers/agent-bottom-anchor";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
-import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
+import {
+ attachComposerTestImage,
+ pasteComposerTestImage,
+ enterComposerFullscreen,
+ exitComposerFullscreen,
+ resizeComposerViewport,
+ selectComposerDraftRange,
+ expectComposerDraftSelection,
+ expectComposerFullscreenControls,
+ sendComposerWithButton,
+ queueComposerWithButton,
+ configureComposerQueueSend,
+ activateRetainedComposerTab,
+ switchComposerTabWithKeyboard,
+ expectWorkspaceHeaderHidden,
+ expectWorkspaceHeaderVisible,
+ recordVisibleComposerGeometry,
+ expectNoCollapsedVisibleComposer,
+ expectAttachmentPill,
+ expectQueuedMessageButton,
+ 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 {
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
@@ -133,3 +165,471 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
await agent.cleanup();
}
});
+
+/** Freeze the page's timers and animation frames until `page.clock.resume()`. */
+async function pausePageClock(page: Page): Promise {
+ 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 {
+ 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 {
+ await composerLocator(page).fill(text);
+ await expect(composerLocator(page)).toHaveValue(text);
+}
+
+test("compact long drafts stop growing before the header", async ({ page }) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-cap-",
+ title: "Composer cap",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await writeCompactDraft(page, "A long draft line\n".repeat(40));
+ await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+test("compact fullscreen editing preserves the live draft and selection and returns to chat after send", async ({
+ page,
+}) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-fullscreen-",
+ title: "Fullscreen composer",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ 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 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();
+ }
+});
+
+test("fullscreen submit rejection retains the editable draft and supports retry", async ({
+ page,
+}) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-fullscreen-retry-",
+ title: "Fullscreen retry",
+ featureValues: { mockPromptRejections: 1 },
+ });
+ const draft = "First line\nSecond line\nThird line";
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await writeCompactDraft(page, draft);
+ await enterComposerFullscreen(page);
+ await sendComposerWithButton(page);
+ 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 expectWorkspaceHeaderVisible(page);
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+async function blurComposer(page: Page): Promise {
+ 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> {
+ 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();
+ }
+});
+
+test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({
+ page,
+}) => {
+ await configureComposerQueueSend(page);
+ await resizeComposerViewport(page, "portrait");
+ const agent = await startRunningMockAgent(page, {
+ prefix: "composer-fullscreen-queue-",
+ model: "one-minute-stream",
+ prompt: "Stay running for fullscreen queue test.",
+ });
+ try {
+ await writeCompactDraft(page, "Queued first line\nSecond line\nThird line");
+ await enterComposerFullscreen(page);
+ await expectComposerFullscreenControls(page);
+ await queueComposerWithButton(page);
+ await expect(composerLocator(page)).toHaveValue("");
+ await expectWorkspaceHeaderVisible(page);
+ await expectQueuedMessageButton(page);
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+test("image paste stays attached to the active editor across fullscreen entry and exit", async ({
+ page,
+}) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-paste-remount-",
+ title: "Composer paste",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await writeCompactDraft(page, "First line\nSecond line\nThird line");
+ expect(await pasteComposerTestImage(page)).toBe(true);
+ await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(1);
+ await enterComposerFullscreen(page);
+ expect(await pasteComposerTestImage(page)).toBe(true);
+ await exitComposerFullscreen(page);
+ await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(2);
+ expect(await pasteComposerTestImage(page)).toBe(true);
+ await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(3);
+ } finally {
+ await agent.cleanup();
+ }
+});
+
+test("retained agent tabs preserve fullscreen editing and paste when switched away and back", async ({
+ page,
+}) => {
+ const workspace = await seedWorkspace({ repoPrefix: "composer-retained-fullscreen-" });
+ try {
+ const first = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "First retained chat",
+ });
+ const second = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Second retained chat",
+ });
+ await openWorkspaceWithAgents(page, [first, second]);
+ await resizeComposerViewport(page, "portrait");
+ 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);
+ // 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 expectComposerFullscreenFitsVisibleViewport(page);
+ await expectComposerDraftSelection(page, 4, 17);
+ await expect(composerLocator(page)).toBeFocused();
+ expect(await pasteComposerTestImage(page)).toBe(true);
+ await exitComposerFullscreen(page);
+ await expectAttachmentPill(page, "composer-image-attachment-pill");
+ } finally {
+ await workspace.cleanup();
+ }
+});
+
+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,
+}) => {
+ const workspace = await seedWorkspace({ repoPrefix: "composer-retained-capacity-" });
+ try {
+ const first = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "First capacity chat",
+ });
+ const second = await createMockIdleAgent(workspace.client, {
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Second capacity chat",
+ });
+ await openWorkspaceWithAgents(page, [first, second]);
+ await resizeComposerViewport(page, "portrait");
+ const draft = "Retained long line\n".repeat(40);
+ await writeCompactDraft(page, draft);
+ await recordVisibleComposerGeometry(page);
+ await activateRetainedComposerTab(page, first.title);
+ await writeCompactDraft(page, "Other visible draft");
+ await activateRetainedComposerTab(page, second.title);
+ await expect(composerLocator(page)).toHaveValue(draft);
+ await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
+ await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeVisible();
+ await expectNoCollapsedVisibleComposer(page);
+ } finally {
+ await workspace.cleanup();
+ }
+});
+
+async function shrinkVisibleViewport(page: Page): Promise {
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send("Emulation.setPageScaleFactor", { pageScaleFactor: 2 });
+ await expect.poll(() => page.evaluate(() => window.visualViewport!.height)).toBe(422);
+ await cdp.detach();
+}
+
+async function panVisibleViewport(page: Page): Promise {
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send("Input.dispatchTouchEvent", {
+ type: "touchStart",
+ touchPoints: [{ x: 4, y: 300 }],
+ });
+ for (let y = 290; y >= 100; y -= 10) {
+ await cdp.send("Input.dispatchTouchEvent", {
+ type: "touchMove",
+ touchPoints: [{ x: 4, y }],
+ });
+ }
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
+ await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetTop)).toBeGreaterThan(0);
+ await cdp.detach();
+}
+
+test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "composer-visible-viewport-",
+ title: "Visible viewport composer",
+ });
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await writeCompactDraft(page, "First line\nSecond line\nThird line");
+ await enterComposerFullscreen(page);
+ await expectComposerFullscreenFitsVisibleViewport(page);
+ await shrinkVisibleViewport(page);
+ await expectComposerFullscreenFitsVisibleViewport(page);
+ await panVisibleViewport(page);
+ await expectComposerFullscreenFitsVisibleViewport(page);
+ } finally {
+ await agent.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts
index d46ae25d35..7e1d7d661c 100644
--- a/packages/app/e2e/browser/context-window-meter.spec.ts
+++ b/packages/app/e2e/browser/context-window-meter.spec.ts
@@ -275,10 +275,16 @@ for (const theme of ["light", "dark"] as const) {
await expect(card).toBeVisible();
});
- await test.step("Refresh replaces the card with the source's new report", async () => {
- usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
+ await test.step("Refresh keeps the card open and replaces it with the new report", async () => {
+ const refreshed = gate();
+ usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] });
const card = contextWindowDetails(page);
await refreshUsageCard(card, "Claude");
+ await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled();
+ // Past the card's close grace, with the pointer still on the card.
+ await page.waitForTimeout(500);
+ await expect(card).toBeVisible();
+ refreshed.open();
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
});
diff --git a/packages/app/e2e/browser/creation-idempotency.spec.ts b/packages/app/e2e/browser/creation-idempotency.spec.ts
index 39e73dbded..7658addf72 100644
--- a/packages/app/e2e/browser/creation-idempotency.spec.ts
+++ b/packages/app/e2e/browser/creation-idempotency.spec.ts
@@ -128,3 +128,46 @@ for (const scenario of [
await creation.expectAgentCount(1);
});
}
+
+for (const entry of [
+ {
+ name: "agent draft",
+ open: (creation: Awaited>) =>
+ creation.openAgentDraft(),
+ button: "Send message",
+ settled: async (_creation: Awaited>) => {},
+ },
+ {
+ name: "new workspace",
+ open: (creation: Awaited>) =>
+ creation.openWorkspaceForm("local"),
+ button: "Create",
+ settled: (creation: Awaited>) =>
+ creation.expectOneCreatedWorkspace(),
+ },
+]) {
+ test(`${entry.name} becomes an agent tab when the first prompt is rejected`, async ({
+ creation,
+ promptRejection,
+ page,
+ }, testInfo) => {
+ void promptRejection;
+ const prompt =
+ "Continue the attached conversation.\n" + "Earlier conversation context.\n".repeat(100);
+ await entry.open(creation);
+ await creation.submitPrompt(prompt, entry.button);
+ await entry.settled(creation);
+ await creation.expectCreatedAgentError();
+ await creation.expectPromptBeforeError(prompt);
+ await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
+ await creation.reloadAgent();
+ await creation.expectPromptBeforeError(prompt);
+ await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
+ await creation.expectPromptVisible(
+ "emit 1 coalesced agent stream updates for a corrected prompt.",
+ );
+ await creation.expectAssistantReply();
+ await creation.expectAgentCount(1);
+ await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0);
+ });
+}
diff --git a/packages/app/e2e/browser/creation-old-daemon.spec.ts b/packages/app/e2e/browser/creation-old-daemon.spec.ts
index ff28d513ef..4b418b304a 100644
--- a/packages/app/e2e/browser/creation-old-daemon.spec.ts
+++ b/packages/app/e2e/browser/creation-old-daemon.spec.ts
@@ -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();
+ let created = Promise.withResolvers();
+ 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();
+ },
release() {
const send = release;
release = () => {};
diff --git a/packages/app/e2e/browser/custom-model-removal.spec.ts b/packages/app/e2e/browser/custom-model-removal.spec.ts
new file mode 100644
index 0000000000..dc6fd84616
--- /dev/null
+++ b/packages/app/e2e/browser/custom-model-removal.spec.ts
@@ -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);
+ });
+}
diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts
index 43e1e03863..da729381d2 100644
--- a/packages/app/e2e/browser/file-editing.spec.ts
+++ b/packages/app/e2e/browser/file-editing.spec.ts
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import {
+ expandFolder,
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
@@ -70,6 +71,14 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise {
+ const image = page.getByRole("button", { name, exact: true });
+ await expect(image).toBeVisible();
+ await expect
+ .poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth))
+ .toBeGreaterThan(0);
+}
+
function watchRequestsTo(page: Page, origin: string): string[] {
const requests: string[] = [];
page.on("request", (request) => {
@@ -96,6 +105,64 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
+ test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "encoded-markdown-link-",
+ title: "Non-ASCII file link",
+ initialPrompt: "Show the report link",
+ featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
+ });
+ try {
+ await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
+ await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
+ await openAgentRoute(page, session);
+ await openAssistantMarkdownLink(page, "报告");
+ await expectFileTabOpen(page, "docs/reports/开户赠金.md");
+ await expect(
+ page
+ .getByTestId("workspace-file-pane")
+ .filter({ visible: true })
+ .getByText("Report opened", { exact: true }),
+ ).toBeVisible();
+ await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
+ } finally {
+ await session.cleanup();
+ }
+ });
+
+ for (const order of ["raw-first", "href-first"] as const) {
+ test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "percent-link-cache-",
+ title: "Distinct file links",
+ initialPrompt: "Show both filenames",
+ featureValues: {
+ mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).",
+ },
+ });
+ try {
+ await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n");
+ await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n");
+ await openAgentRoute(page, session);
+ const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"];
+ for (const link of links) {
+ await returnToAssistant(page, session.agentId);
+ if (link === "raw") {
+ await openAssistantInlinePath(page, "literal%20.md");
+ await expectFileTabOpen(page, "literal%20.md");
+ await expectPreviewText(page, "Literal filename");
+ } else {
+ await openAssistantMarkdownLink(page, "Decoded");
+ await expectFileTabOpen(page, "literal .md");
+ await expectPreviewText(page, "Decoded filename");
+ }
+ }
+ } finally {
+ await session.cleanup();
+ }
+ });
+ }
+
test("jumps to each relative reference to the same file", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "relative-reference-qa-",
@@ -154,7 +221,7 @@ test.describe("CodeMirror workspace file editing", () => {
const fileLink = page.getByRole("link", { name: "Open target" }).first();
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.hover();
- await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
+ await expect(fileLink).toHaveAttribute("title", "src/target.ts:42-45");
} finally {
await workspace.cleanup();
}
@@ -626,6 +693,40 @@ test.describe("CodeMirror workspace file editing", () => {
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
});
+ test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
+ test.setTimeout(90_000);
+ const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
+ await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
+ await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
+ await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
+ await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
+ await writeFile(
+ path.join(workspace.repoPath, "docs", "guide.md"),
+ [
+ "# Guide",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ await workspace.navigateTo();
+ await openFileExplorer(page);
+ await expandFolder(page, "docs");
+ await openFileFromExplorer(page, "guide.md");
+ await expectFileTabOpen(page, "docs/guide.md");
+
+ const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
+ await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
+ await expectMarkdownImageLoaded(page, "sibling");
+ await expectMarkdownImageLoaded(page, "hero");
+ await visibleFilePane.screenshot({
+ path: test.info().outputPath("markdown-relative-images.png"),
+ });
+ });
+
test("previews and refreshes an HTML plan while preserving source access", async ({
page,
withWorkspace,
@@ -757,3 +858,24 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
});
});
+
+async function openAssistantMarkdownLink(page: Page, name: string): Promise {
+ await page.getByRole("link", { name, exact: true }).first().click();
+}
+
+async function returnToAssistant(page: Page, agentId: string): Promise {
+ await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click();
+}
+
+async function openAssistantInlinePath(page: Page, token: string): Promise {
+ await page.getByText(token, { exact: true }).click();
+}
+
+async function expectPreviewText(page: Page, text: string): Promise {
+ await expect(
+ page
+ .getByTestId("workspace-file-pane")
+ .filter({ visible: true })
+ .getByText(text, { exact: true }),
+ ).toBeVisible();
+}
diff --git a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts
index bbc73b797e..faf7285a62 100644
--- a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts
+++ b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts
@@ -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);
+});
diff --git a/packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts b/packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts
new file mode 100644
index 0000000000..60e13b70a6
--- /dev/null
+++ b/packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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({ 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);
+ }
+});
diff --git a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts
index e7dbde3bd9..906efcfb42 100644
--- a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts
+++ b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts
@@ -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();
+ }
+});
diff --git a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts
index d4444d6f22..22b2b8a6c5 100644
--- a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts
+++ b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts
@@ -6,6 +6,15 @@ import {
selectWorkspaceIsolation,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
+import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+import {
+ composerLocator,
+ expectComposerVisible,
+ fillComposerDraft,
+ enterComposerFullscreen,
+ resizeComposerViewport,
+ expectComposerFullscreenControls,
+} from "../support/helpers/composer";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
type WebSocketMessage = string | Buffer;
@@ -196,3 +205,52 @@ test.describe("New Workspace dictation submit", () => {
}
});
});
+
+async function startDictationWithShortcut(page: Page): Promise {
+ await composerLocator(page).press("Control+d");
+}
+
+test("dictation shortcut leaves retained fullscreen and exposes recording controls", async ({
+ page,
+}) => {
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "fullscreen-dictation-",
+ title: "Fullscreen dictation shortcut",
+ });
+ await installSyntheticMicrophone(page);
+ const harness = await installDictationFailureHarness(page);
+ const draft = "First line\nSecond line\nThird line";
+ try {
+ await resizeComposerViewport(page, "portrait");
+ await openAgentRoute(page, agent);
+ await expectComposerVisible(page);
+ await fillComposerDraft(page, draft);
+ await enterComposerFullscreen(page);
+ await resizeComposerViewport(page, "landscape");
+ await expectComposerFullscreenControls(page);
+ await startDictationWithShortcut(page);
+ await harness.waitForAudio();
+ await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Insert transcription", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Insert transcription and send", exact: true }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
+ await expect(composerLocator(page)).toBeEditable();
+ await expect(composerLocator(page)).toHaveValue(draft);
+ await resizeComposerViewport(page, "portrait");
+ await enterComposerFullscreen(page);
+ await resizeComposerViewport(page, "landscape");
+ await composerLocator(page).press("Control+Shift+d");
+ await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
+ await expect(
+ page.getByText("Realtime voice is disabled in this test.", { exact: true }),
+ ).toBeVisible();
+ await expect(composerLocator(page)).toHaveValue(draft);
+ } finally {
+ await agent.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts
index 16b277b537..d393966ee8 100644
--- a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts
+++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts
@@ -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,
@@ -9,13 +20,12 @@ import {
expectTerminalPreviewCommand,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
- seedTerminalProfiles,
+ launchTrigger,
selectLaunchOption,
submitTerminalLaunch,
terminalLaunchSubmit,
terminalPromptInput,
type TerminalProfile,
- type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// Someone who wants a terminal agent instead of a chat: they pick a profile
@@ -42,17 +52,18 @@ const BARE_PROFILE: TerminalProfile = {
args: ["-c", "echo bare-launch-static-line; exec cat"],
};
+test.use({
+ e2eDaemonConfig: { version: 1, daemon: { terminalProfiles: [PROMPT_PROFILE, BARE_PROFILE] } },
+});
+
test.describe("New workspace: launching a terminal", () => {
let workspace: SeededWorkspace;
- let profileSeed: TerminalProfileSeed;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" });
- profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
});
test.afterEach(async () => {
- await profileSeed.restore();
await workspace?.cleanup();
});
@@ -97,4 +108,165 @@ test.describe("New workspace: launching a terminal", () => {
await expectTerminalOutputContains(page, "bare-launch-static-line");
});
});
+
+ test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({
+ page,
+ }) => {
+ await gotoAppShell(page);
+ await waitForSidebarHydration(page);
+ await openNewWorkspaceComposer(page, {
+ projectKey: workspace.projectKey,
+ projectDisplayName: workspace.projectDisplayName,
+ });
+ await page.setViewportSize({ width: 390, height: 844 });
+ const prompt = "First replacement line\nSecond replacement line\nThird replacement line";
+ const selectProfile = async (profile: TerminalProfile) => {
+ await launchTrigger(page).click();
+ const option = page.getByRole("menuitem").filter({ hasText: profile.name });
+ await option.click();
+ await expect(option).not.toBeVisible();
+ };
+ const fullscreen = page.getByRole("button", { name: "Fullscreen", exact: true });
+
+ await test.step("restore a multiline prompt after a read-only profile", async () => {
+ await selectProfile(PROMPT_PROFILE);
+ await fillTerminalPrompt(page, prompt);
+ await expect(fullscreen).toBeVisible();
+ await selectProfile(BARE_PROFILE);
+ await expect(terminalPromptInput(page)).toHaveCount(0);
+ await selectProfile(PROMPT_PROFILE);
+ await expect(terminalPromptInput(page)).toHaveValue(prompt);
+ await expect(fullscreen).toBeVisible();
+ });
+
+ await test.step("restore the locally edited prompt and measure subsequent typing", async () => {
+ await fillTerminalPrompt(page, "Short draft");
+ await expect(fullscreen).toHaveCount(0);
+ await selectProfile(BARE_PROFILE);
+ await selectProfile(PROMPT_PROFILE);
+ await expect(terminalPromptInput(page)).toHaveValue("Short draft");
+ await expect(fullscreen).toHaveCount(0);
+ await terminalPromptInput(page).press("End");
+ await terminalPromptInput(page).press("Shift+Enter");
+ await terminalPromptInput(page).pressSequentially("Second");
+ await terminalPromptInput(page).press("Shift+Enter");
+ await terminalPromptInput(page).pressSequentially("Third");
+ await expect(terminalPromptInput(page)).toHaveValue("Short draft\nSecond\nThird");
+ await expect(fullscreen).toBeVisible();
+ });
+ });
+});
+
+async function expectNewWorkspaceHeaderHidden(page: Page): Promise {
+ await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
+}
+
+async function expectNewWorkspaceHeaderVisible(page: Page): Promise {
+ 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 {
+ 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);
+ });
+ });
});
diff --git a/packages/app/e2e/browser/pair-device-relay.spec.ts b/packages/app/e2e/browser/pair-device-relay.spec.ts
index 8c4e306a1e..dbd3814d38 100644
--- a/packages/app/e2e/browser/pair-device-relay.spec.ts
+++ b/packages/app/e2e/browser/pair-device-relay.spec.ts
@@ -1,6 +1,8 @@
import { test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
+ expectPairingOffer,
+ expectCompletePairingQr,
expectRelayConsent,
openPairDeviceModal,
preparePairingHost,
@@ -18,3 +20,20 @@ test("opens relay consent in browser web", async ({ page }) => {
await daemon.close();
}
});
+
+test("keeps the pairing QR code inside its compact tile", async ({ page }, testInfo) => {
+ const daemon = await startIsolatedHostDaemon("pair-device-browser-compact", {
+ mutableRelay: { enabled: true },
+ });
+ try {
+ await preparePairingHost(page, daemon);
+ await page.setViewportSize({ width: 320, height: 568 });
+ await openPairDeviceModal(page);
+ await expectPairingOffer(page);
+
+ await expectCompletePairingQr(page);
+ await page.screenshot({ path: testInfo.outputPath("pairing-compact.png") });
+ } finally {
+ await daemon.close();
+ }
+});
diff --git a/packages/app/e2e/browser/plan-card-markdown.spec.ts b/packages/app/e2e/browser/plan-card-markdown.spec.ts
index b5d7cfe1e7..5dcf2a2cec 100644
--- a/packages/app/e2e/browser/plan-card-markdown.spec.ts
+++ b/packages/app/e2e/browser/plan-card-markdown.spec.ts
@@ -1,15 +1,9 @@
+import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { waitForPermissionPrompt } from "../support/helpers/permissions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
-// The plan card has to pass its own markdown parser. Drop that prop and
-// react-native-markdown-display silently supplies one with `typographer: true`
-// (node_modules/react-native-markdown-display/src/index.js:139-141), which
-// turns (c) into ©, curls straight quotes, and rewrites --- as an em dash.
-// No unit test can catch that, because the defect lives in the JSX.
-//
-// Nothing here is provider-specific: the mock provider drives it, and the
-// behavior is the same for every agent.
+// Literal shell commands and copyright markers must survive Markdown rendering.
test.describe("Plan card markdown", () => {
test("renders plan text verbatim", async ({ page }) => {
test.setTimeout(180_000);
@@ -25,6 +19,7 @@ test.describe("Plan card markdown", () => {
await waitForPermissionPrompt(page, 120_000);
const planCard = page.getByTestId("permission-plan-card");
+ await expect(planCard.locator(".enrm-text")).toBeVisible();
await expect(planCard).toContainText("(c)");
await expect(planCard).toContainText('--name="my repo"');
await expect(planCard).toContainText("---buzz");
@@ -35,3 +30,161 @@ test.describe("Plan card markdown", () => {
}
});
});
+
+test("chat and file preview share the library renderer and resolve file links", async ({
+ page,
+}, testInfo) => {
+ await withSharedMarkdownFiles(page, async () => {
+ const message = page.getByTestId("assistant-message");
+ await expect(message).toHaveCount(1);
+ await expect(message.locator(".enrm-text > p").first()).toContainText("First paragraph.");
+ await expect(message.locator(".enrm-text > p").nth(1)).toContainText("Second paragraph.");
+ await expect(message.locator("blockquote")).toContainText("A quote");
+ await expect(message.getByRole("table")).toContainText("42");
+ await expect(message.getByRole("cell").nth(0)).toHaveText("Answer");
+ await expect(message.getByRole("cell").nth(1)).toHaveText("");
+ await expect(message.getByRole("cell").nth(2)).toHaveText("42");
+ await expect(message.locator("p div")).toHaveCount(0);
+ await openSharedMarkdownFile(page, "README.md");
+ const preview = page.getByTestId("markdown-preview-frame");
+ await expect(preview.locator(".enrm-text")).toContainText("Second paragraph.");
+ await expect(preview.getByRole("table")).toContainText("42");
+ await returnToSharedMarkdownChat(page);
+ await expectFileLinkTooltip(
+ page,
+ message.getByRole("link", { name: "src/说明.md:2-3", exact: true }),
+ "packages/app/src/说明.md:2-3",
+ );
+ await openSharedMarkdownFile(page, "src/说明.md:2-3");
+ await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
+ await openSharedMarkdownFile(page, "packages/app/src/说明.md:2-3");
+ await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
+ await openSharedMarkdownFile(page, "Space file");
+ await expect(
+ page.getByText("Explicit link opened the space filename.", { exact: true }),
+ ).toBeVisible();
+ await openSharedMarkdownFile(page, "my%20file.ts");
+ await expect(
+ page.getByText("Inline code opened the literal percent filename.", { exact: true }),
+ ).toBeVisible();
+ await testInfo.attach("shared-renderer", {
+ body: await page.screenshot(),
+ contentType: "image/png",
+ });
+ });
+});
+async function expectFileLinkTooltip(page: Page, link: Locator, path: string) {
+ await link.hover();
+ await expect(page.getByRole("tooltip")).toHaveText(path);
+}
+
+async function withSharedMarkdownFiles(page: Page, run: () => Promise) {
+ const { writeFile, mkdir } = await import("node:fs/promises");
+ const source =
+ "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key || Value |\n| --- | --- | --- |\n| Answer || 42 |\n\nOpen `README.md` or `src/说明.md:2-3`, or packages/app/src/说明.md:2-3.\n\n[Space file](my%20file.ts) and `my%20file.ts`.";
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "shared-markdown-",
+ title: "Shared Markdown",
+ featureValues: { mockAssistantResponse: source },
+ initialPrompt: "Show Markdown",
+ });
+ try {
+ await writeFile(`${session.cwd}/README.md`, source);
+ await writeFile(`${session.cwd}/my file.ts`, "Explicit link opened the space filename.");
+ await writeFile(
+ `${session.cwd}/my%20file.ts`,
+ "Inline code opened the literal percent filename.",
+ );
+ await mkdir(`${session.cwd}/packages/app/src`, { recursive: true });
+ await writeFile(
+ `${session.cwd}/packages/app/src/说明.md`,
+ "# Nested file\n\nUnicode suffix lookup succeeded.",
+ );
+ await session.client.waitForFinish(session.agentId, 30_000);
+ // A cold render must work even when a separate parser chunk cannot be fetched.
+ await page.route("**/wasm/md4c.bundle?**", (route) => route.abort("failed"));
+ await openAgentRoute(page, session);
+ await run();
+ } finally {
+ await session.cleanup();
+ }
+}
+
+async function returnToSharedMarkdownChat(page: Page) {
+ await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
+}
+
+async function openSharedMarkdownFile(page: Page, name: string) {
+ await returnToSharedMarkdownChat(page);
+ await page.getByTestId("assistant-message").getByRole("link", { name, exact: true }).click();
+}
+
+test("linked inline code keeps code typography and wraps long paths", async ({
+ page,
+}, testInfo) => {
+ const longPath =
+ "packages/app/src/components/markdown/renderer/very-long-directory-name/another-long-directory-name/file.ts:12";
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "inline-code-style-",
+ title: "Inline code style",
+ featureValues: {
+ mockAssistantResponse:
+ "Compare `ordinary_token`, `file.ts:12`, `https://example.com`. At the end of this line open `" +
+ longPath +
+ "`.",
+ },
+ initialPrompt: "Show inline code",
+ });
+ try {
+ await session.client.waitForFinish(session.agentId, 30_000);
+ await openAgentRoute(page, session);
+ const message = page.getByTestId("assistant-message");
+ await expect(message.getByRole("link", { name: longPath, exact: true })).toBeVisible();
+ const measurements = await measureInlineCode(message);
+ await testInfo.attach("inline-code-measurements", {
+ body: JSON.stringify(measurements, null, 2),
+ contentType: "application/json",
+ });
+ expect(measurements.codes).toHaveLength(4);
+ const [plain, file, url, long] = measurements.codes;
+ for (const linked of [file, url, long]) {
+ expect(linked.fontSize).toBe(plain.fontSize);
+ expect(linked.fontFamily).toBe(plain.fontFamily);
+ expect(linked.backgroundColor).toBe(plain.backgroundColor);
+ expect(linked.color).toBe(linked.anchorColor);
+ expect(linked.color).not.toBe(plain.color);
+ }
+ expect(long.lines).toBeGreaterThan(1);
+ expect(long.right).toBeLessThanOrEqual(measurements.right + 1);
+ } finally {
+ await session.cleanup();
+ }
+});
+
+async function measureInlineCode(message: Locator) {
+ return message.evaluate((element) => {
+ // Constrain the paragraph to exercise an inline path at a line boundary.
+ const paragraph = element.querySelector("p")!;
+ paragraph.style.width = "300px";
+ const codes = Array.from(paragraph.querySelectorAll("code"), (code) => {
+ const style = getComputedStyle(code);
+ const range = document.createRange();
+ range.selectNodeContents(code);
+ const rects = Array.from(range.getClientRects());
+ return {
+ text: code.textContent,
+ fontSize: style.fontSize,
+ fontFamily: style.fontFamily,
+ backgroundColor: style.backgroundColor,
+ color: style.color,
+ anchorColor: code.closest("a") ? getComputedStyle(code.closest("a")!).color : null,
+ whiteSpace: style.whiteSpace,
+ overflowWrap: style.overflowWrap,
+ rects: rects.map((rect) => ({ left: rect.left, right: rect.right, top: rect.top })),
+ lines: new Set(rects.map((rect) => rect.top)).size,
+ right: Math.max(...rects.map((rect) => rect.right)),
+ };
+ });
+ return { codes, right: paragraph.getBoundingClientRect().right };
+ });
+}
diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts
index 7e32f61350..84b4cc63a2 100644
--- a/packages/app/e2e/browser/plugin-management.spec.ts
+++ b/packages/app/e2e/browser/plugin-management.spec.ts
@@ -1,3 +1,4 @@
+import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
@@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows(
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
- "Plugin source is neither an existing directory nor a Git URL",
+ `Plugin directory does not exist: ${missingSource}`,
);
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
await capturePluginInstallForm(page, testInfo, "compact-error");
@@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
}
+
+test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
+ const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
+ await installPluginWithUntrustedDescription(pluginEnvironment, description);
+ await gotoAppShell(page);
+ await openPluginSettings(page);
+ const text = page.getByText(description, { exact: true });
+ await expect(text).toBeVisible();
+ // This field must remain a text node, with no Markdown links, images, or HTML children.
+ expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
+});
+
+async function installPluginWithUntrustedDescription(
+ environment: PluginEnvironment,
+ description: string,
+) {
+ const directory = await createDirectoryPlugin(
+ environment.directory,
+ "plain-text-plugin",
+ description,
+ "Plain text plugin",
+ );
+ await environment.client.installPluginSource({ source: directory });
+}
diff --git a/packages/app/e2e/browser/plugin-popover.spec.ts b/packages/app/e2e/browser/plugin-popover.spec.ts
new file mode 100644
index 0000000000..8ce3b35513
--- /dev/null
+++ b/packages/app/e2e/browser/plugin-popover.spec.ts
@@ -0,0 +1,47 @@
+import { expect, test, type Page } from "../support/fixtures";
+import { composerLocator, submitMessageWithButton } from "../support/helpers/composer";
+import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
+import { withPopoverTimelinePlugin } from "../support/helpers/plugin-timeline";
+
+async function openPreviewWhileTyping(page: Page): Promise {
+ await composerLocator(page).focus();
+ await page.getByRole("button", { name: "Hover plugin preview", exact: true }).hover();
+ await expect(page.getByRole("dialog", { name: "Plugin preview" })).toBeVisible();
+ await expect(composerLocator(page)).toBeFocused();
+}
+
+async function dismissPreview(page: Page): Promise {
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog", { name: "Plugin preview" })).not.toBeVisible();
+}
+
+test("Escape dismisses a plugin preview before interrupting the agent", async ({ page }) => {
+ test.setTimeout(120_000);
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "plugin-popover-",
+ title: "Plugin preview keyboard regression",
+ model: "e2e-fast-stream",
+ });
+ try {
+ await withPopoverTimelinePlugin(async () => {
+ await openAgentRoute(page, agent);
+ await submitMessageWithButton(page, "Show the plugin preview.");
+ await agent.client.waitForFinish(agent.agentId, 30_000);
+ await expect(page.getByRole("button", { name: "Hover plugin preview" })).toBeVisible();
+ await submitMessageWithButton(page, "withhold synthetic user message until interrupted");
+ const stop = page.getByRole("button", { name: "Stop agent", exact: true });
+ await expect(stop).toBeVisible();
+
+ await openPreviewWhileTyping(page);
+ await dismissPreview(page);
+ await expect(stop).toBeVisible();
+ await expect(composerLocator(page)).toBeFocused();
+
+ await page.keyboard.press("Escape");
+ await expect(stop).not.toBeVisible();
+ await agent.client.waitForFinish(agent.agentId, 15_000);
+ });
+ } finally {
+ await agent.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/project-path-spaces.spec.ts b/packages/app/e2e/browser/project-path-spaces.spec.ts
new file mode 100644
index 0000000000..3b3478f5bb
--- /dev/null
+++ b/packages/app/e2e/browser/project-path-spaces.spec.ts
@@ -0,0 +1,64 @@
+import { execFileSync } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { expect, test as base } from "../support/fixtures";
+import { gotoWorkspace } from "../support/helpers/launcher";
+import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
+
+const test = base.extend<{ projectWithTrailingSpace: string }>({
+ projectWithTrailingSpace: async ({ e2eWorkerClient: client }, provide) => {
+ const root = await mkdtemp(path.join(tmpdir(), "paseo-project-path-spaces-"));
+ try {
+ const repoPath = path.join(root, "repo ");
+ execFileSync("git", ["init", "-b", "main", repoPath], { stdio: "pipe" });
+ execFileSync(
+ "git",
+ [
+ "-c",
+ "user.name=Paseo Test",
+ "-c",
+ "user.email=test@getpaseo.local",
+ "-c",
+ "commit.gpgsign=false",
+ "commit",
+ "--allow-empty",
+ "-m",
+ "initial",
+ ],
+ { cwd: repoPath, stdio: "pipe" },
+ );
+ await writeFile(path.join(repoPath, "visible-change.txt"), "a change\n");
+ const added = await client.addProject(repoPath);
+ expect(added.error).toBeNull();
+ const projectId = added.project!.projectId;
+ try {
+ const created = await client.createWorkspace({
+ source: { kind: "directory", path: repoPath, projectId },
+ });
+ expect(created.error).toBeNull();
+ await provide(created.workspace!.id);
+ } finally {
+ await client.removeProject(projectId);
+ }
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+ },
+});
+
+test.skip(
+ process.platform === "win32",
+ "Windows does not preserve trailing spaces in folder names",
+);
+
+test("Changes shows files in a project whose folder ends in a space", async ({
+ page,
+ projectWithTrailingSpace,
+}, testInfo) => {
+ await gotoWorkspace(page, projectWithTrailingSpace);
+ await openChangesTreePanel(page);
+ await expect(page.getByText("visible-change.txt", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText("Not a git repository", { exact: true })).toHaveCount(0);
+ await page.screenshot({ path: testInfo.outputPath("changes.png") });
+});
diff --git a/packages/app/e2e/browser/provider-removal.spec.ts b/packages/app/e2e/browser/provider-removal.spec.ts
index 7e17a1fbe0..b72fb868d6 100644
--- a/packages/app/e2e/browser/provider-removal.spec.ts
+++ b/packages/app/e2e/browser/provider-removal.spec.ts
@@ -10,16 +10,29 @@ import {
openSettingsHost,
openSettingsHostSection,
} from "../support/helpers/settings";
+import { copyPluginExample } from "../support/helpers/plugin-fixture";
const CUSTOM_PROVIDER = {
id: "junie",
name: "Junie",
} as const;
+const PLUGIN_PROVIDER = {
+ id: "direct-example",
+ name: "Direct provider example",
+ pluginId: "provider-direct-example",
+} as const;
+
interface ProviderRemovalDaemonClient {
connect(): Promise;
close(): Promise;
- patchDaemonConfig(config: { removeProviders?: string[] }): Promise;
+ getDaemonConfig(): Promise<{ config: { pluginsEnabled?: boolean } }>;
+ patchDaemonConfig(config: {
+ removeProviders?: string[];
+ pluginsEnabled?: boolean;
+ }): Promise;
+ installDirectoryPlugin(path: string): Promise;
+ removePlugin(pluginId: string): Promise;
getProvidersSnapshot(): Promise<{
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
}>;
@@ -93,4 +106,45 @@ test.describe("provider removal", () => {
await client.close().catch(() => undefined);
}
});
+
+ test("does not offer Remove for a plugin-registered provider", async ({ page }) => {
+ test.setTimeout(120_000);
+ const client = await connectDaemonClient({
+ clientIdPrefix: "provider-removal-e2e",
+ });
+
+ const plugin = await copyPluginExample("provider-direct");
+ try {
+ const previous = await client.getDaemonConfig();
+ try {
+ await client.patchDaemonConfig({ pluginsEnabled: true });
+ await client.installDirectoryPlugin(plugin.directory);
+
+ await gotoAppShell(page);
+ await openSettings(page);
+ await openSettingsHost(page, getServerId());
+ await openSettingsHostSection(page, getServerId(), "providers");
+
+ const pluginProviderRow = page.getByRole("button", {
+ name: `${PLUGIN_PROVIDER.name} provider details`,
+ exact: true,
+ });
+ await expect(pluginProviderRow).toBeVisible();
+ await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
+
+ // The provider survives a removeProviders patch pointed at it: the
+ // plugin, not config.json, owns the definition.
+ await client.patchDaemonConfig({ removeProviders: [PLUGIN_PROVIDER.id] });
+ await expect(pluginProviderRow).toBeVisible();
+ } finally {
+ await client.removePlugin(PLUGIN_PROVIDER.pluginId).catch(() => undefined);
+ await client
+ .patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled })
+ .catch(() => undefined);
+ }
+ } finally {
+ await client.close().catch(() => undefined);
+ await plugin.cleanup();
+ }
+ });
});
diff --git a/packages/app/e2e/browser/question-prompt-pagination.spec.ts b/packages/app/e2e/browser/question-prompt-pagination.spec.ts
index b014b2d1cd..1d75ae2839 100644
--- a/packages/app/e2e/browser/question-prompt-pagination.spec.ts
+++ b/packages/app/e2e/browser/question-prompt-pagination.spec.ts
@@ -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,
diff --git a/packages/app/e2e/browser/sidebar-help.spec.ts b/packages/app/e2e/browser/sidebar-help.spec.ts
index 2d038abbdb..4c0162e8d8 100644
--- a/packages/app/e2e/browser/sidebar-help.spec.ts
+++ b/packages/app/e2e/browser/sidebar-help.spec.ts
@@ -183,13 +183,16 @@ async function openCompactSidebar(page: Page): Promise {
await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible();
}
+const MIN_TARGET_SIZE = 44;
+
async function expectFingerSizedFooterTargets(page: Page): Promise {
const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button");
await expect(buttons).toHaveCount(5);
for (const button of await buttons.all()) {
const bounds = await button.boundingBox();
- expect(bounds?.width).toBeGreaterThanOrEqual(44);
- expect(bounds?.height).toBeGreaterThanOrEqual(44);
+ // Layout can report a 44pt box as 43.99999, so compare at a hundredth of a point.
+ expect(bounds?.width).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01);
+ expect(bounds?.height).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01);
// Glyphs stay at the composer toolbar size (some optically smaller); only the target grows.
const glyphWidth = await button.locator("svg").first().getAttribute("width");
expect(Number(glyphWidth)).toBeLessThanOrEqual(20);
diff --git a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts
index cf2f448913..7b4c36bf97 100644
--- a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts
+++ b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts
@@ -21,6 +21,15 @@ test("project and workspace kebabs open action sheets on compact layouts", async
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await waitForSidebarHydration(page);
+ await page.getByTestId("sidebar-display-preferences-menu").click();
+ await expect(page.getByRole("menuitem")).toHaveText([
+ /^Grouping/,
+ /^Title/,
+ "Show",
+ "Show background",
+ ]);
+ await closeMenuSheet(page);
+
await page
.getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`)
.click();
diff --git a/packages/app/e2e/browser/sidebar-project-filter.spec.ts b/packages/app/e2e/browser/sidebar-project-filter.spec.ts
index df9b9afddf..a2f8d14348 100644
--- a/packages/app/e2e/browser/sidebar-project-filter.spec.ts
+++ b/packages/app/e2e/browser/sidebar-project-filter.spec.ts
@@ -29,6 +29,16 @@ test.describe("Sidebar project filter", () => {
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
await expect(betaRow).toBeVisible({ timeout: 30_000 });
+ await page.getByTestId("sidebar-display-preferences-menu").click();
+ await expect(page.getByRole("menuitem")).toHaveText([
+ /^Grouping/,
+ /^Title/,
+ "Show",
+ "Project",
+ "Show background",
+ ]);
+ await closeSidebarDisplayPreferences(page);
+
await openSidebarProjectFilter(page);
await expect(page.getByTestId("sidebar-project-filter-all")).toBeVisible();
await toggleProjectFilter(page, alpha.projectKey);
diff --git a/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts
index 21ad51a6bb..6ed14d159a 100644
--- a/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts
+++ b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts
@@ -1,6 +1,7 @@
import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
+import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
function workspaceRowTestId(workspaceId: string): string {
@@ -73,3 +74,75 @@ test.describe("Sidebar workspace rename", () => {
}
});
});
+
+async function toggleBackgroundWorkspaces(page: Page): Promise {
+ await page.getByLabel("Display preferences", { exact: true }).click();
+ await page.getByRole("menuitem", { name: "Show background", exact: true }).click();
+ await page.keyboard.press("Escape");
+}
+
+async function expectWorkspaceInSidebar(page: Page, workspaceId: string): Promise {
+ // Rows have no single accessible role: their nested title and actions share this container.
+ await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toBeVisible({ timeout: 30_000 });
+}
+
+async function expectWorkspaceHidden(page: Page, workspaceId: string): Promise {
+ await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toHaveCount(0);
+}
+
+async function reconnectApp(page: Page): Promise {
+ await page.reload();
+ await expect(page.getByLabel("Display preferences", { exact: true })).toBeVisible();
+}
+
+async function expectWorkerOpen(page: Page): Promise {
+ await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
+ timeout: 30_000,
+ });
+ await expect(page.getByText("Background reviewer", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText("Background review prompt", { exact: true }).first()).toBeVisible();
+}
+
+test("background workspace stays directly accessible across sidebar toggles and reconnect", async ({
+ page,
+}, testInfo) => {
+ const workspace = await seedWorkspace({
+ repoPrefix: "sidebar-background-",
+ title: "Background review",
+ background: true,
+ });
+ try {
+ const worker = await workspace.client.createAgent({
+ provider: "mock",
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: "Background reviewer",
+ initialPrompt: "Background review prompt",
+ model: "e2e-fast-stream",
+ modeId: "load-test",
+ });
+ await test.step("Default sidebar hides background work", async () => {
+ await gotoAppShell(page);
+ await expectWorkspaceHidden(page, workspace.workspaceId);
+ });
+ await test.step("Show background exposes the fully replicated workspace", async () => {
+ await toggleBackgroundWorkspaces(page);
+ await expectWorkspaceInSidebar(page, workspace.workspaceId);
+ await reconnectApp(page);
+ await expectWorkspaceInSidebar(page, workspace.workspaceId);
+ });
+ await test.step("Direct agent navigation survives hiding and reconnect", async () => {
+ await toggleBackgroundWorkspaces(page);
+ await expectWorkspaceHidden(page, workspace.workspaceId);
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: worker.id });
+ await expectWorkerOpen(page);
+ await expectWorkspaceHidden(page, workspace.workspaceId);
+ await reconnectApp(page);
+ await expectWorkerOpen(page);
+ await expectWorkspaceHidden(page, workspace.workspaceId);
+ await page.screenshot({ path: testInfo.outputPath("background-direct-open.png") });
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts
index 700d1e200f..8071b3b4f3 100644
--- a/packages/app/e2e/browser/sidebar-workspace.spec.ts
+++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts
@@ -6,6 +6,11 @@ import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
+ expectWorkspaceHoverCardClosed,
+ expectWorkspaceHoverCardOpen,
+ expectWorkspaceHoverCardStaysOpen,
+ focusWorkspaceRowWithKeyboard,
+ movePointerAwayFromWorkspaceHoverCard,
openMobileAgentSidebar,
pinWorkspaceFromSidebar,
} from "../support/helpers/sidebar";
@@ -404,6 +409,52 @@ test.describe("Sidebar workspace list", () => {
}
});
+ test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => {
+ const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" });
+
+ try {
+ await gotoAppShell(page);
+ await waitForSidebarProject(page, path.basename(workspace.repoPath));
+ const row = await openWorkspaceFromSidebar(page, workspace.workspaceId);
+ await movePointerAwayFromWorkspaceHoverCard(page);
+ await expectWorkspaceHoverCardClosed(page);
+
+ await row.click();
+ await expect(row).toBeFocused();
+ await expectWorkspaceHoverCardOpen(page);
+ await movePointerAwayFromWorkspaceHoverCard(page);
+ await expectWorkspaceHoverCardClosed(page);
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
+ test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
+ const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
+
+ try {
+ await gotoAppShell(page);
+ await waitForSidebarProject(page, path.basename(workspace.repoPath));
+ await openWorkspaceFromSidebar(page, workspace.workspaceId);
+ await movePointerAwayFromWorkspaceHoverCard(page);
+ await expectWorkspaceHoverCardClosed(page);
+
+ await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
+ await expectWorkspaceHoverCardOpen(page);
+ await movePointerAwayFromWorkspaceHoverCard(page);
+ await expectWorkspaceHoverCardStaysOpen(page);
+ await page.keyboard.press("Escape");
+ await expectWorkspaceHoverCardClosed(page);
+
+ await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
+ await expectWorkspaceHoverCardOpen(page);
+ await page.keyboard.press("Tab");
+ await expectWorkspaceHoverCardClosed(page);
+ } finally {
+ await workspace.cleanup();
+ }
+ });
+
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "sidebar-mark-unread-",
diff --git a/packages/app/e2e/browser/terminal-profile-icons.spec.ts b/packages/app/e2e/browser/terminal-profile-icons.spec.ts
new file mode 100644
index 0000000000..29d786cfd3
--- /dev/null
+++ b/packages/app/e2e/browser/terminal-profile-icons.spec.ts
@@ -0,0 +1,71 @@
+import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
+import { expect, test, type Page } from "../support/fixtures";
+import { gotoAppShell } from "../support/helpers/app";
+import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
+import { openLaunchMenu } from "../support/helpers/new-workspace-launch";
+import { seedWorkspace } from "../support/helpers/seed-client";
+import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
+
+// Harmless commands: this journey opens the profile menu without launching them.
+test.use({
+ e2eDaemonConfig: {
+ version: 1,
+ daemon: {
+ terminalProfiles: [
+ { id: "antigravity", name: "Antigravity artwork", command: "/bin/sh", icon: "agy" },
+ { id: "cursor", name: "Cursor artwork", command: "/bin/sh", icon: "cursor" },
+ { id: "unknown", name: "Unknown artwork", command: "/bin/sh", icon: "unknown-icon" },
+ ],
+ },
+ },
+});
+
+function profileIcon(page: Page, name: string) {
+ return page.getByRole("menuitem", { name: new RegExp(`^${name} artwork`) }).locator("svg");
+}
+
+async function expectBundledArtwork(page: Page, name: string, artwork: string) {
+ const icon = profileIcon(page, name);
+ await expect(icon).toBeVisible();
+ const expectedPaths = await page.evaluate((xml) => {
+ const document = new DOMParser().parseFromString(xml, "image/svg+xml");
+ return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
+ }, artwork);
+ await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
+ for (const [index, path] of expectedPaths.entries()) {
+ await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
+ }
+ await expect(icon).toHaveAttribute("width", "14");
+ await expect(icon).toHaveAttribute("height", "14");
+ const terminalColor = await page
+ .getByRole("menuitem", { name: "Terminal", exact: true })
+ .locator("svg")
+ .evaluate((element) => getComputedStyle(element).stroke);
+ await expect(icon.locator("path").first()).toHaveCSS("fill", terminalColor);
+}
+
+async function expectUnknownArtworkFallback(page: Page) {
+ const icon = profileIcon(page, "Unknown");
+ await expect(icon).toBeVisible();
+ await expect(icon.locator('path[d="M12 8V4H8"]')).toHaveCount(1);
+}
+
+test("terminal profiles show bundled artwork while unknown icons keep the Bot fallback", async ({
+ page,
+}) => {
+ const workspace = await seedWorkspace({ repoPrefix: "terminal-icons-" });
+ try {
+ await gotoAppShell(page);
+ await waitForSidebarHydration(page);
+ await openNewWorkspaceComposer(page, {
+ projectKey: workspace.projectKey,
+ projectDisplayName: workspace.projectDisplayName,
+ });
+ await openLaunchMenu(page);
+ await expectBundledArtwork(page, "Antigravity", ACP_PROVIDER_ICON_SVGS.agy);
+ await expectBundledArtwork(page, "Cursor", ACP_PROVIDER_ICON_SVGS.cursor);
+ await expectUnknownArtworkFallback(page);
+ } finally {
+ await workspace.cleanup();
+ }
+});
diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts
similarity index 86%
rename from packages/app/e2e/browser/usage-screen.spec.ts
rename to packages/app/e2e/browser/usage-modal.spec.ts
index 1dfe89ae19..a923971d09 100644
--- a/packages/app/e2e/browser/usage-screen.spec.ts
+++ b/packages/app/e2e/browser/usage-modal.spec.ts
@@ -18,13 +18,15 @@ import {
installLoginUsage,
installCodexWindowUsage,
expectCodexReportedWindows,
- openUsage,
refreshLoginUsage,
hoverUsageWindow,
} from "../support/helpers/usage-login";
import {
claudeAndCodexReports,
expectPinnedUsage,
+ openCompactSidebar,
+ openUsageFromIcon,
+ openUsageFromItem,
pinRow,
openUsageOptions,
closeUsageOptions,
@@ -98,7 +100,41 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
};
}
-test.describe("usage screen", () => {
+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,
}) => {
@@ -134,14 +170,13 @@ test.describe("usage screen", () => {
});
await gotoAppShell(page);
- await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
- await expect(page).toHaveURL(/\/usage$/);
+ await openUsageFromItem(page);
await usage.waitForListRequests(1);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(hostFilter(page)).toHaveCount(0);
- await qaScreenshot(page, "phase7-usage-screen-one-host");
+ await qaScreenshot(page, "usage-modal-one-host");
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
@@ -151,6 +186,21 @@ test.describe("usage screen", () => {
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();
@@ -176,7 +226,7 @@ test.describe("usage screen", () => {
windows: [{ id: "weekly", label: "Weekly", usedPct }],
},
});
- // The sidebar summary and the screen each load reports; only a card's Refresh forces one.
+ // The sidebar summary and the modal each load reports; only a card's Refresh forces one.
const usage = await installUsageReportsFixture(page, {
lists: [
(request) =>
@@ -187,7 +237,7 @@ test.describe("usage screen", () => {
});
await gotoAppShell(page);
- await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
+ await openUsageFromItem(page);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
@@ -224,7 +274,9 @@ test.describe("usage screen", () => {
});
await page.setViewportSize({ width: 390, height: 844 });
- await page.goto("/usage");
+ await gotoAppShell(page);
+ await openCompactSidebar(page);
+ await openUsageFromIcon(page);
await usage.waitForListRequests(1);
const group = page.getByTestId(`usage-host-${serverId}`);
@@ -238,8 +290,8 @@ test.describe("usage screen", () => {
const usage = await installUsageReportsFixture(page, { usageSupported: false });
await gotoAppShell(page);
- // Without reports the footer has no Usage item; its Usage icon opens the screen.
- await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click();
+ // Without reports the footer has no Usage item; its Usage icon opens Usage.
+ await openUsageFromIcon(page);
await expect(
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
@@ -250,7 +302,7 @@ test.describe("usage screen", () => {
expect(usage.listRequests()).toHaveLength(0);
});
- test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({
+ test("a host picked in the Usage modal is the sidebar's host too, after a reload", async ({
page,
}) => {
test.setTimeout(420_000);
@@ -273,7 +325,7 @@ test.describe("usage screen", () => {
// Nothing picked and no workspace open: the first host.
await expectPinnedUsage(page, ["31% Weekly"]);
- await usageItem(page).click();
+ await openUsageFromItem(page);
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
await hostFilter(page).click();
@@ -282,7 +334,7 @@ test.describe("usage screen", () => {
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
- await qaScreenshot(page, "usage-screen-picked-host");
+ await qaScreenshot(page, "usage-modal-picked-host");
await expectPinnedUsage(page, ["12% Weekly"]);
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
@@ -292,6 +344,7 @@ test.describe("usage screen", () => {
port: secondary.port,
});
await expectPinnedUsage(page, ["12% Weekly"]);
+ await openUsageFromItem(page);
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
).toBeVisible({ timeout: 30_000 });
@@ -320,7 +373,7 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
});
try {
await gotoAppShell(page);
- await openUsage(page);
+ await openUsageFromIcon(page);
await expect(
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
).toBeVisible();
@@ -366,7 +419,8 @@ test("compact usage rows always show the pin glyph", async ({ page }) => {
try {
await page.setViewportSize({ width: 390, height: 844 });
await gotoAppShell(page);
- await openUsage(page);
+ await openCompactSidebar(page);
+ await openUsageFromIcon(page);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2);
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1");
@@ -413,7 +467,7 @@ for (const shape of ["seven-day-only", "Spark"] as const) {
await page.setViewportSize({ width: 1440, height: 900 });
await gotoAppShell(page);
await expect(usageItem(page)).toBeVisible();
- await usageItem(page).click();
+ await openUsageFromItem(page);
await qaScreenshot(page, `codex-${shape}-card-and-summary`);
await expectCodexReportedWindows(page, shape);
await qaScreenshot(page, `codex-${shape}-pins`);
@@ -439,7 +493,9 @@ for (const theme of ["light", "dark"] as const) {
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize(viewport);
- await page.goto("/usage");
+ await gotoAppShell(page);
+ if (size === "compact") await openCompactSidebar(page);
+ await openUsageFromIcon(page);
const screen = page.getByTestId(`usage-host-${getServerId()}`);
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
const settings = page.getByTestId("usage-options-menu");
@@ -501,7 +557,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
]);
try {
await gotoAppShell(page);
- await openUsage(page);
+ await openUsageFromIcon(page);
await expectLoginErrors(page, [
"Codex: Usage API returned 500. Try again.",
"OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.",
diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts
index a6d48daf80..7e7ee1a68c 100644
--- a/packages/app/e2e/browser/usage-settings.spec.ts
+++ b/packages/app/e2e/browser/usage-settings.spec.ts
@@ -8,7 +8,11 @@ import {
installUsageReportsFixture,
type UsageReportsFixture,
} from "../support/helpers/usage-reports";
-import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item";
+import {
+ openUsageFromIcon,
+ refreshAllUsage,
+ showUsageAs,
+} from "../support/helpers/usage-sidebar-item";
const ICON = '';
@@ -99,7 +103,8 @@ test.describe("usage settings", () => {
// The shared percentages setting applies to the host section.
await expect(page.getByTestId("usage-options-menu")).toBeVisible();
const hostUsageUrl = page.url();
- await page.goto("/usage");
+ await gotoAppShell(page);
+ await openUsageFromIcon(page);
await showUsageAs(page, "remaining");
await page.goto(hostUsageUrl);
await expect(card.getByText("30% left")).toBeVisible();
diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts
index e43ccdc4a3..27fac6691c 100644
--- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts
+++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts
@@ -2,6 +2,11 @@ import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
+import { gotoWorkspace, waitForTabBar } from "../support/helpers/launcher";
+import {
+ connectNewWorkspaceDaemonClient,
+ openProjectViaDaemon,
+} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import {
@@ -14,28 +19,29 @@ import {
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
- closeUsageSheet,
+ closeUsage,
+ closeUsageFromBackdrop,
closeUsageOptions,
- openUsageSheetFromIcon,
expectNoUsageItem,
- expectOnUsageScreen,
expectPinnedUsage,
expectSummaryInSidebar,
expectUnpinnableRows,
- leaveUsageScreen,
openCompactSidebar,
- openUsageScreenFromIcon,
+ openUsageFromIcon,
+ openUsageFromItem,
pinRow,
+ scrollUsageToWindow,
setSummaryInSidebar,
openUsageOptions,
showUsageAs,
togglePin,
usageItem,
- usageSheet,
+ usageModal,
installTallUsageSource,
} from "../support/helpers/usage-sidebar-item";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
+import { createTempGitRepo } from "../support/helpers/workspace";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
@@ -75,49 +81,98 @@ test.describe("Usage item", () => {
await expectNoUsageItem(page);
await test.step("the icon opens a sheet without leaving the current screen", async () => {
- await openUsageSheetFromIcon(page);
- await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible();
+ await openUsageFromIcon(page);
+ await expect(usageModal(page).getByText("Claude", { exact: true })).toBeVisible();
await expect(page).toHaveURL(startingUrl);
await qaScreenshot(page, "compact-usage-icon-sheet");
await setSummaryInSidebar(page, true);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
- await closeUsageSheet(page);
+ await closeUsageFromBackdrop(page);
});
await test.step("the summary opens the same sheet", async () => {
- await usageItem(page).click();
- await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible();
+ await openUsageFromItem(page);
+ await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
await expect(page).toHaveURL(startingUrl);
await qaScreenshot(page, "compact-usage-summary-sheet");
- await closeUsageSheet(page);
+ await closeUsageFromBackdrop(page);
});
});
- test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
+
+ test("on desktop Usage opens as a dialog over the workspace and closes back to it", async ({
page,
}) => {
- test.setTimeout(120_000);
- const source = await installTallUsageSource();
+ const repo = await createTempGitRepo("usage-modal-");
+ const client = await connectNewWorkspaceDaemonClient();
try {
- await page.setViewportSize(COMPACT);
+ const project = await openProjectViaDaemon(client, repo.path);
+ await page.setViewportSize(WIDE);
+ await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
- await gotoAppShell(page);
- await openCompactSidebar(page);
- await expect(usageItem(page)).toBeInViewport();
- await usageItem(page).click();
- const content = page.getByTestId("sidebar-usage-sheet-content");
- await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
- await waitForSettledPosition(content);
- const bounds = (await content.boundingBox())!;
- expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
- await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
- await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded();
- await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
- await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded();
- await expect(content.getByText("Window 1", { exact: true })).toBeInViewport();
+ await gotoWorkspace(page, project.workspaceId);
+ const workspaceUrl = page.url();
+
+ await test.step("the summary opens a dialog over the workspace", async () => {
+ await openUsageFromItem(page);
+ const dialog = page.getByRole("dialog");
+ await expect(dialog.getByText("Claude", { exact: true })).toBeVisible();
+ await expect(
+ dialog.getByRole("button", { name: "Refresh all", exact: true }),
+ ).toBeVisible();
+ await expect(page).toHaveURL(workspaceUrl);
+ await qaScreenshot(page, "desktop-usage-dialog");
+ });
+
+ await test.step("Close returns to the same workspace", async () => {
+ await closeUsage(page);
+ await expect(page).toHaveURL(workspaceUrl);
+ await waitForTabBar(page);
+ });
+
+ await test.step("the icon opens it again, and Escape closes it", async () => {
+ await openUsageFromIcon(page);
+ await page.keyboard.press("Escape");
+ await expect(usageModal(page)).toHaveCount(0);
+ await expect(page).toHaveURL(workspaceUrl);
+ });
} finally {
- await source.cleanup();
+ await client.close().catch(() => undefined);
+ await repo.cleanup().catch(() => undefined);
}
});
+
+ const layouts = {
+ desktop: { viewport: WIDE, showSidebar: async (_page: Page) => {} },
+ compact: { viewport: COMPACT, showSidebar: openCompactSidebar },
+ };
+ for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) {
+ test(`long usage reports scroll between their first and last windows on ${size}`, async ({
+ page,
+ }) => {
+ test.setTimeout(120_000);
+ const source = await installTallUsageSource();
+ try {
+ await page.setViewportSize(viewport);
+ await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
+ await gotoAppShell(page);
+ await showSidebar(page);
+ await expect(usageItem(page)).toBeInViewport();
+ await openUsageFromItem(page);
+ const content = usageModal(page);
+ await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
+ await waitForSettledPosition(content);
+ await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
+ await qaScreenshot(page, `${size}-usage-scroll-top`);
+ await scrollUsageToWindow(page, "Window 20");
+ await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
+ await qaScreenshot(page, `${size}-usage-scroll-bottom`);
+ await scrollUsageToWindow(page, "Window 1");
+ await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
+ } finally {
+ await source.cleanup();
+ }
+ });
+ }
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
page,
}) => {
@@ -146,8 +201,7 @@ test.describe("Usage item", () => {
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
- await usageItem(page).click();
- await expectOnUsageScreen(page);
+ await openUsageFromItem(page);
await qaScreenshot(page, "default-pins-card");
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
@@ -172,17 +226,13 @@ test.describe("Usage item", () => {
await expect(usageItem(page)).not.toHaveText("Usage");
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible();
- await qaScreenshot(page, "usage-screen-header", {
- kind: "element",
- locator: page.getByTestId("page-title").locator(".."),
- });
await qaScreenshot(page, "usage-card-refresh", {
kind: "element",
locator: screen.getByTestId("usage-report-claude:default"),
});
});
- await test.step("remaining flips the Usage item and the Usage screen", async () => {
+ await test.step("remaining flips the Usage item and the Usage modal", async () => {
await showUsageAs(page, "remaining");
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
@@ -194,23 +244,15 @@ test.describe("Usage item", () => {
).toBeVisible();
});
- await test.step("on desktop the Usage item opens the Usage screen", async () => {
- await gotoAppShell(page);
- await expect(page).not.toHaveURL(/\/usage$/);
- await usageItem(page).click();
- await expectOnUsageScreen(page);
- await expect(page.getByTestId("usage-expanded")).toHaveCount(0);
- });
-
await test.step("on a phone the Usage item opens a bottom sheet", async () => {
+ await closeUsage(page);
await page.setViewportSize(COMPACT);
- await leaveUsageScreen(page);
await openCompactSidebar(page);
await expect(usageItem(page)).toBeInViewport();
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await qaScreenshot(page, "compact-footer");
- await usageItem(page).click();
- const sheet = usageSheet(page);
+ await openUsageFromItem(page);
+ const sheet = usageModal(page);
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
await expect(sheet.getByText("88% left")).toBeVisible();
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
@@ -231,25 +273,20 @@ test.describe("Usage item", () => {
await closeUsageOptions(page);
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
await expect(sheet).toBeVisible();
- await expect(page).not.toHaveURL(/\/usage$/);
await waitForSettledPosition(sheet);
const sheetBox = (await sheet.boundingBox())!;
- // The Usage sheet reserves the top 20% as its backdrop.
+ // The sheet leaves the screen above it as its backdrop.
expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
await qaScreenshot(page, "compact-sheet");
- await qaScreenshot(page, "phase7-compact-sheet");
- // Tap the backdrop above the sheet.
- await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
- await expect(sheet).toHaveCount(0);
+ await closeUsageFromBackdrop(page);
await page.setViewportSize(WIDE);
- await usageItem(page).click();
- await expectOnUsageScreen(page);
});
await test.step("a reload keeps the pins and the toggle", async () => {
await page.reload();
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
+ await openUsageFromItem(page);
await openUsageOptions(page);
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-selected",
@@ -260,13 +297,15 @@ test.describe("Usage item", () => {
});
await test.step("the Settings usage section shares the pins and the toggle", async () => {
- const usageUrl = page.url();
+ const workspaceUrl = page.url();
+ await closeUsage(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const section = page.getByTestId("usage-card");
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
- await page.goto(usageUrl);
+ await page.goto(workspaceUrl);
+ await openUsageFromItem(page);
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
});
@@ -275,6 +314,7 @@ test.describe("Usage item", () => {
await togglePin(screen, "Codex", "Weekly");
await expectNoUsageItem(page);
await page.reload();
+ await openUsageFromIcon(page);
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
await expectNoUsageItem(page);
await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked();
@@ -282,6 +322,7 @@ test.describe("Usage item", () => {
await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked();
await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked();
await qaScreenshot(page, "explicit-empty-pins-after-reload");
+ await closeUsage(page);
});
await test.step("Settings > Sidebar lists the Usage item", async () => {
@@ -325,8 +366,7 @@ test("without summary data the footer drops the Usage item and keeps the Usage i
await expectNoUsageItem(page);
await expectFooterSeparator(page, false);
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
- await page.locator('[data-testid="sidebar-usage-icon"]:visible').click();
- await expectOnUsageScreen(page);
+ await openUsageFromIcon(page);
});
test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({
@@ -340,13 +380,13 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
const claude = screen.getByTestId("usage-report-claude:default");
await test.step("off by default: no Usage item, and rows that do not pin", async () => {
- await openUsageScreenFromIcon(page);
+ await openUsageFromIcon(page);
await expectNoUsageItem(page);
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
await expectUnpinnableRows(claude);
await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default"));
await expectSummaryInSidebar(page, false);
- await qaScreenshot(page, "usage-screen-summary-off");
+ await qaScreenshot(page, "usage-summary-off");
});
await test.step("on: the Usage item and the pins appear", async () => {
@@ -355,7 +395,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
await togglePin(screen, "Claude", "Weekly");
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
- await qaScreenshot(page, "usage-screen-summary-on");
+ await qaScreenshot(page, "usage-summary-on");
});
await test.step("off then on again, after a reload, brings back the saved pins", async () => {
@@ -363,7 +403,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
await expectNoUsageItem(page);
await expectUnpinnableRows(claude);
await page.reload();
- await expectOnUsageScreen(page);
+ await openUsageFromIcon(page);
await expectSummaryInSidebar(page, false);
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
await expectUnpinnableRows(claude);
@@ -391,8 +431,7 @@ test("released hosts supply source logos through the client conversion", async (
await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath);
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
- await usageItem(page).click();
- await expectOnUsageScreen(page);
+ await openUsageFromItem(page);
const screen = page.getByTestId(`usage-host-${getServerId()}`);
await expect(
screen.getByTestId("usage-report-claude").locator("svg path").first(),
@@ -400,5 +439,5 @@ test("released hosts supply source logos through the client conversion", async (
await expect(
screen.getByTestId("usage-report-codex").locator("svg path").first(),
).toHaveAttribute("d", codexPath);
- await qaScreenshot(page, "released-host-usage-screen");
+ await qaScreenshot(page, "released-host-usage-modal");
});
diff --git a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts
new file mode 100644
index 0000000000..4853e9daa4
--- /dev/null
+++ b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts
@@ -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 {
+ 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 {
+ 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();
+ }
+});
diff --git a/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts b/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts
index 07e62131b0..72b5908446 100644
--- a/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts
+++ b/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts
@@ -1,3 +1,4 @@
+import type { Page, TestInfo } from "@playwright/test";
import { randomUUID } from "node:crypto";
import { test, expect } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -6,6 +7,31 @@ import type { SeedDaemonClient } from "../support/helpers/seed-client";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
+test.use({ viewport: MOBILE_VIEWPORT, deviceScaleFactor: 2 });
+
+async function openTabSwitcher(page: Page) {
+ await page.getByRole("button", { name: /Switch tabs/ }).click();
+ const footer = page.getByRole("button", { name: "New terminal", exact: true });
+ await expect(footer).toBeVisible();
+ // Visibility alone passes while the sheet is still travelling up from below the viewport.
+ await expect
+ .poll(async () => {
+ const bounds = await footer.boundingBox();
+ return bounds ? bounds.y + bounds.height : Number.POSITIVE_INFINITY;
+ })
+ .toBeLessThanOrEqual(MOBILE_VIEWPORT.height);
+}
+
+async function captureScreen(page: Page, testInfo: TestInfo, name: string) {
+ await page.screenshot({ path: testInfo.outputPath(name + ".png") });
+}
+
+async function createTabFromSwitcher(page: Page, kind: "New agent" | "New terminal") {
+ await openTabSwitcher(page);
+ await page.getByRole("button", { name: kind, exact: true }).click();
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+}
+
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise {
const result = await client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
@@ -20,7 +46,7 @@ async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promi
test.describe("Workspace session rename (compact tab switcher)", () => {
test("renames an agent session from the switcher's per-session actions sheet", async ({
page,
- }) => {
+ }, testInfo) => {
test.setTimeout(120_000);
await page.setViewportSize(MOBILE_VIEWPORT);
@@ -36,8 +62,10 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
timeout: 30_000,
});
+ await captureScreen(page, testInfo, "compact-chat-header");
+
// Open the sessions dropdown (a bottom sheet on compact).
- await page.getByRole("button", { name: /Switch tabs/ }).click();
+ await openTabSwitcher(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
{
timeout: 10_000,
@@ -65,12 +93,58 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
- await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
+ await expect(page.getByText(renamed, { exact: true })).toBeVisible({
timeout: 15_000,
});
+ await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
+ await expect(page.getByText("Rename agent", { exact: true })).toBeHidden();
+ await captureScreen(page, testInfo, "renamed-tab-in-switcher");
} finally {
await session.cleanup();
}
});
});
+
+test("switcher footer creates tabs and returns to the existing chat", async ({
+ page,
+}, testInfo) => {
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "compact-header-actions-",
+ title: "Existing chat",
+ });
+ try {
+ await openAgentRoute(page, session);
+ await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
+
+ await createTabFromSwitcher(page, "New terminal");
+ await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("2");
+ await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1);
+ await captureScreen(page, testInfo, "compact-terminal-header");
+
+ await createTabFromSwitcher(page, "New agent");
+ await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
+ await expect(
+ page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true }),
+ ).toHaveCount(1);
+ await captureScreen(page, testInfo, "compact-draft-header");
+
+ await openTabSwitcher(page);
+ await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
+ await captureScreen(page, testInfo, "switcher-three-tabs");
+ await page.getByText("Existing chat", { exact: true }).click();
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
+
+ await openTabSwitcher(page);
+ await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
+ await page
+ .getByRole("button", { name: "Bottom sheet backdrop" })
+ .click({ position: { x: 10, y: 10 } });
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+ await captureScreen(page, testInfo, "returned-chat-header");
+ } finally {
+ await session.cleanup();
+ }
+});
diff --git a/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts-preconditions.md b/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts-preconditions.md
new file mode 100644
index 0000000000..56dbd6355e
--- /dev/null
+++ b/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts-preconditions.md
@@ -0,0 +1,15 @@
+# Native compact workspace scripts flow
+
+`native-compact-workspace-scripts.android.ad` covers live script status in the compact ⋯ → Scripts sheet when nothing else keeps the host directory live. Start on a phone with a workspace open, the sidebar closed, and no agent tab selected. A viewed agent tab or the open sidebar hides the failure, because either one keeps the directory subscribed.
+
+The workspace's `paseo.json` needs a long-running plain script, stopped before the run. `SCRIPT` defaults to `worker`:
+
+```json
+{ "scripts": { "worker": { "command": "node -e \"setInterval(() => {}, 1000)\"" } } }
+```
+
+The flow starts the script, checks that Stop flips its page to Run, runs it again, and checks that Restart brings it back to running with no other interaction. It ends with the script stopped and the sheet dismissed, so it can be replayed.
+
+```sh
+agent-device replay packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad --platform android --serial emulator-5590 --metro-port 8137
+```
diff --git a/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad b/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad
new file mode 100644
index 0000000000..7a24b7ee8b
--- /dev/null
+++ b/packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad
@@ -0,0 +1,45 @@
+context platform=android timeout=120000 retries=0
+env APP_ID=sh.paseo.debug
+env SCRIPT=worker
+
+open "${APP_ID}"
+wait "id=\"workspace-header-menu-trigger\"" 45000
+wait stable 1000 15000
+press "id=\"workspace-header-menu-trigger\""
+wait "id=\"workspace-header-scripts\"" 10000
+press "id=\"workspace-header-scripts\""
+wait "id=\"workspace-scripts-row-${SCRIPT}\"" 10000
+press "id=\"workspace-scripts-row-${SCRIPT}\""
+wait stable 1000 15000
+wait "id=\"workspace-header-menu-trigger\"" 10000
+press "id=\"workspace-header-menu-trigger\""
+wait "id=\"workspace-header-scripts\"" 10000
+press "id=\"workspace-header-scripts\""
+wait "text" "Running" 10000
+press "id=\"workspace-scripts-row-${SCRIPT}\""
+wait "id=\"workspace-script-${SCRIPT}-stop\"" 10000
+press "id=\"workspace-script-${SCRIPT}-stop\""
+wait "id=\"workspace-script-${SCRIPT}-run\"" 10000
+press "id=\"workspace-script-${SCRIPT}-run\""
+wait stable 1000 15000
+wait "id=\"workspace-header-menu-trigger\"" 10000
+press "id=\"workspace-header-menu-trigger\""
+wait "id=\"workspace-header-scripts\"" 10000
+press "id=\"workspace-header-scripts\""
+wait "text" "Running" 10000
+press "id=\"workspace-scripts-row-${SCRIPT}\""
+wait "id=\"workspace-script-${SCRIPT}-restart\"" 10000
+press "id=\"workspace-script-${SCRIPT}-restart\""
+wait stable 1000 15000
+wait "id=\"workspace-header-menu-trigger\"" 10000
+press "id=\"workspace-header-menu-trigger\""
+wait "id=\"workspace-header-scripts\"" 10000
+press "id=\"workspace-header-scripts\""
+wait "text" "Running" 15000
+press "id=\"workspace-scripts-row-${SCRIPT}\""
+wait "id=\"workspace-script-${SCRIPT}-stop\"" 10000
+press "id=\"workspace-script-${SCRIPT}-stop\""
+wait "id=\"workspace-script-${SCRIPT}-run\"" 10000
+back
+wait stable 1000 15000
+close
diff --git a/packages/app/e2e/mobile/agent-device/native-composer-fullscreen.ios.ad b/packages/app/e2e/mobile/agent-device/native-composer-fullscreen.ios.ad
new file mode 100644
index 0000000000..d991b227b7
--- /dev/null
+++ b/packages/app/e2e/mobile/agent-device/native-composer-fullscreen.ios.ad
@@ -0,0 +1,18 @@
+context platform=ios timeout=60000 retries=0
+# Start with an empty composer in an idle chat or new-agent draft on an iPhone with the software keyboard enabled.
+# Expand a long draft with the keyboard open; Exit fullscreen must remain reachable.
+open sh.paseo.debug
+wait "editable=true" 60000
+fill "editable=true" "iOS fullscreen composer smoke.\n\nLine 1: Check that the composer keeps this draft when expanded and collapsed.\nLine 2: Check that the composer keeps this draft when expanded and collapsed.\nLine 3: Check that the composer keeps this draft when expanded and collapsed.\nLine 4: Check that the composer keeps this draft when expanded and collapsed.\nLine 5: Check that the composer keeps this draft when expanded and collapsed.\nLine 6: Check that the composer keeps this draft when expanded and collapsed.\nLine 7: Check that the composer keeps this draft when expanded and collapsed.\nLine 8: Check that the composer keeps this draft when expanded and collapsed.\nLine 9: Check that the composer keeps this draft when expanded and collapsed.\nLine 10: Check that the composer keeps this draft when expanded and collapsed.\nLine 11: Check that the composer keeps this draft when expanded and collapsed.\nLine 12: Check that the composer keeps this draft when expanded and collapsed.\nLine 13: Check that the composer keeps this draft when expanded and collapsed.\nLine 14: Check that the composer keeps this draft when expanded and collapsed."
+press "label=\"Fullscreen\""
+wait "label=\"Exit fullscreen\" role=button" 5000
+is visible "label=\"Exit fullscreen\" role=button"
+press "label=\"Exit fullscreen\" role=button"
+wait "label=\"Fullscreen\" role=button" 5000
+is visible "label=\"Fullscreen\" role=button"
+press "label=\"Fullscreen\""
+wait "label=\"Exit fullscreen\" role=button" 5000
+is visible "label=\"Exit fullscreen\" role=button"
+press "label=\"Exit fullscreen\" role=button"
+wait "label=\"Fullscreen\" role=button" 5000
+is visible "label=\"Fullscreen\" role=button"
diff --git a/packages/app/e2e/mobile/agent-device/native-question-option-preview-preconditions.md b/packages/app/e2e/mobile/agent-device/native-question-option-preview-preconditions.md
new file mode 100644
index 0000000000..ac920aa0e7
--- /dev/null
+++ b/packages/app/e2e/mobile/agent-device/native-question-option-preview-preconditions.md
@@ -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
+```
diff --git a/packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad b/packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad
new file mode 100644
index 0000000000..f6cd42bbab
--- /dev/null
+++ b/packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad
@@ -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
diff --git a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad
new file mode 100644
index 0000000000..c94e24914d
--- /dev/null
+++ b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad
@@ -0,0 +1,13 @@
+context platform=ios timeout=60000 retries=0
+env APP_ID=sh.paseo.debug
+
+# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
+# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms).
+open "${APP_ID}"
+press "label=\"Open menu\""
+wait "id=\"${WORKSPACE_ROW}\"" 10000
+longpress "id=\"${WORKSPACE_ROW}\"" 250
+press "id=\"${WORKSPACE_ROW}\""
+# A released drag must not swallow the next tap: navigation closes the sidebar.
+wait "label=\"Open menu\"" 5000
+close
diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad
index 63f5fb0728..335f269b13 100644
--- a/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad
+++ b/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
-wait "id=\"workspace-header-menu-trigger\"" 20000
-press "id=\"workspace-header-menu-trigger\""
-press "id=\"workspace-header-new-terminal\""
+wait "id=\"workspace-tab-switcher-trigger\"" 20000
+press "id=\"workspace-tab-switcher-trigger\""
+press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0
diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad
index 4988c9afdd..2b5e5fcfdb 100644
--- a/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad
+++ b/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
-wait "id=\"workspace-header-menu-trigger\"" 20000
-press "id=\"workspace-header-menu-trigger\""
-press "id=\"workspace-header-new-terminal\""
+wait "id=\"workspace-tab-switcher-trigger\"" 20000
+press "id=\"workspace-tab-switcher-trigger\""
+press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-surface\"" 10000
press "id=\"terminal-surface\""
type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0
diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad
index 9ef099b5a7..11f75b62b3 100644
--- a/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad
+++ b/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
-wait "id=\"workspace-header-menu-trigger\"" 20000
-press "id=\"workspace-header-menu-trigger\""
-press "id=\"workspace-header-new-terminal\""
+wait "id=\"workspace-tab-switcher-trigger\"" 20000
+press "id=\"workspace-tab-switcher-trigger\""
+press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\""
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh
index ba86400132..49c907dba9 100755
--- a/packages/app/e2e/mobile/composer-keyboard/android.sh
+++ b/packages/app/e2e/mobile/composer-keyboard/android.sh
@@ -660,9 +660,9 @@ adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agen
ad wait "text=\"${AGENT_TITLE}\"" 10000
run_host_scenario chat
ad keyboard dismiss || true
-ad press 'id="workspace-header-menu-trigger"' --settle
-ad wait 'id="workspace-header-new-agent"' 10000
-ad press 'id="workspace-header-new-agent"' --settle
+ad press 'id="workspace-tab-switcher-trigger"' --settle
+ad wait 'id="workspace-tab-switcher-new-agent"' 10000
+ad press 'id="workspace-tab-switcher-new-agent"' --settle
ad wait 'text="New Agent"' 10000
run_host_scenario workspace-draft
ad keyboard dismiss || true
diff --git a/packages/app/e2e/mobile/native-text-reflow/README.md b/packages/app/e2e/mobile/native-text-reflow/README.md
deleted file mode 100644
index b92666cc58..0000000000
--- a/packages/app/e2e/mobile/native-text-reflow/README.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# Native iOS text geometry regression
-
-Run on the Namespace Mac with a fresh simulator development build. Use the PID
-returned by `xcrun simctl launch sh.paseo.debug`.
-Attach only to your task's app, while no device replay is running:
-
-```sh
-PASEO_NATIVE_TEXT_REFLOW_PID=12345 \
-PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \
-bash packages/app/e2e/mobile/native-text-reflow/ios.sh
-```
-
-The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
-and resizes its content container from 500 to 240 and back to 500 points. It
-asserts the inner frame follows both widths and the same selection survives.
-It uses public UIView/UITextView operations, without mocking React Native.
-LLDB detaches after the probe. An assertion failure or evaluation error fails the
-shell command; the log distinguishes them.
-
-This covers native container resizing and selection retention. It does not cover
-Fabric event delivery, chat row height, reader anchoring, or the on-screen copy
-menu. Those require the real chat journey: read an older multiline response,
-open/close Explorer, resize/rotate, inspect complete paragraphs and bullets,
-retain expanded/collapsed details, and check a stationary reader's position.
-Accessibility exposes full text even when glyphs are clipped, so a text wait
-alone does not verify that journey.
diff --git a/packages/app/e2e/mobile/native-text-reflow/ios.py b/packages/app/e2e/mobile/native-text-reflow/ios.py
deleted file mode 100644
index 7a4cbd5125..0000000000
--- a/packages/app/e2e/mobile/native-text-reflow/ios.py
+++ /dev/null
@@ -1,55 +0,0 @@
-import lldb
-
-
-def __lldb_init_module(debugger, _internal_dict):
- debugger.HandleCommand("expression -l objc++ -- @import UIKit")
- frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
- options = lldb.SBExpressionOptions()
- options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
- result = frame.EvaluateExpression(
- r'''({
- UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
- auto hostFrame = host.frame;
- hostFrame.size.width = 500;
- hostFrame.size.height = 200;
- host.frame = hostFrame;
- UIView *container = (UIView *)[host performSelector:@selector(contentView)];
- container.frame = host.bounds;
- UITextView *text = nil;
- for (UIView *child in host.subviews) {
- if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child;
- for (UIView *nested in child.subviews) {
- if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested;
- }
- }
- text.frame = container.bounds;
- text.font = [UIFont systemFontOfSize:16];
- text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection.";
- auto range = text.selectedRange;
- range.location = 12;
- range.length = 9;
- text.selectedRange = range;
- auto frame = container.frame;
- frame.size.width = 240;
- container.frame = frame;
- [container layoutIfNeeded];
- CGFloat narrow = text.frame.size.width;
- auto selection = text.selectedRange;
- frame.size.width = 500;
- container.frame = frame;
- [container layoutIfNeeded];
- CGFloat wide = text.frame.size.width;
- BOOL ok = text != nil && narrow == 240 && wide == 500 &&
- selection.location == text.selectedRange.location &&
- selection.length == text.selectedRange.length &&
- selection.location == 12 && selection.length == 9;
- [NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
- ok ? @"PASS" : @"FAIL", narrow, wide,
- (unsigned long)text.selectedRange.location,
- (unsigned long)text.selectedRange.length];
- })''',
- options,
- )
- if result.GetError().Fail():
- raise RuntimeError(result.GetError().GetCString())
- print(result.GetObjectDescription())
diff --git a/packages/app/e2e/mobile/native-text-reflow/ios.sh b/packages/app/e2e/mobile/native-text-reflow/ios.sh
deleted file mode 100644
index b4e9a07a22..0000000000
--- a/packages/app/e2e/mobile/native-text-reflow/ios.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}"
-: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}"
-
-if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then
- echo "Expected a running PaseoDebug simulator app" >&2
- exit 1
-fi
-
-probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
- -o "command script import $probe_dir/ios.py" \
- -o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
-
-grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
-grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
diff --git a/packages/app/e2e/mobile/pair-device/android.mjs b/packages/app/e2e/mobile/pair-device/android.mjs
new file mode 100644
index 0000000000..7ab660c2b0
--- /dev/null
+++ b/packages/app/e2e/mobile/pair-device/android.mjs
@@ -0,0 +1,43 @@
+// Start on Settings > Host > Pair device with relay enabled in the Android app.
+// node packages/app/e2e/mobile/pair-device/android.mjs
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+
+const [, , session] = process.argv;
+assert(session, "Pass the agent-device session for the connected Android app");
+
+function ad(...args) {
+ return execFileSync("agent-device", [...args, "--session", session], {
+ encoding: "utf8",
+ timeout: 60_000,
+ });
+}
+
+ad(
+ "press",
+ 'label="Pair a device, Scan a QR code or copy a link to connect your phone to this host"',
+ "--settle",
+);
+ad("wait", "text", "Pairing QR code", "10000");
+const { data } = JSON.parse(ad("snapshot", "-i", "--json"));
+const sheet = data.nodes.find((node) => node.label === "Bottom Sheet");
+const qr = data.nodes.find((node) => node.label === "Pairing QR code");
+assert(sheet?.rect, "Pairing sheet must be visible");
+assert(qr?.rect, "Pairing QR code must be visible");
+assert(qr.rect.x > sheet.rect.x, "QR must have an unclipped left margin inside the sheet");
+assert(
+ qr.rect.x + qr.rect.width < sheet.rect.x + sheet.rect.width,
+ "QR must have an unclipped right margin inside the sheet",
+);
+assert(Math.abs(qr.rect.width - qr.rect.height) <= 1, "Complete QR must remain square");
+assert(qr.rect.y >= sheet.rect.y, "QR top must be visible inside the sheet");
+assert(
+ qr.rect.y + qr.rect.height <= sheet.rect.y + sheet.rect.height,
+ "QR bottom must be visible inside the sheet",
+);
+assert(
+ data.nodes.some((node) => node.label === "Copy" && node.enabled),
+ "Copy must remain available",
+);
+console.log("PASS: complete square QR fits inside the pairing sheet", qr.rect);
+ad("press", 'label="Close"', "--settle");
diff --git a/packages/app/e2e/mobile/system-bars/android.mjs b/packages/app/e2e/mobile/system-bars/android.mjs
new file mode 100644
index 0000000000..8201f3dc12
--- /dev/null
+++ b/packages/app/e2e/mobile/system-bars/android.mjs
@@ -0,0 +1,62 @@
+// Open Settings > Appearance in a native Android app loaded from this checkout.
+// AGENT_DEVICE_STATE_DIR= node packages/app/e2e/mobile/system-bars/android.mjs
+// Requires English UI and a dedicated emulator. Leaves System theme and light OS appearance.
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { setTimeout as delay } from "node:timers/promises";
+
+const [, , session, serial, appId] = process.argv;
+assert(session && serial && appId, "Pass the agent-device session, adb serial, and app ID");
+
+function ad(...args) {
+ return execFileSync("agent-device", [...args, "--session", session], {
+ encoding: "utf8",
+ timeout: 60_000,
+ });
+}
+
+function windowState() {
+ const state = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
+ encoding: "utf8",
+ timeout: 10_000,
+ });
+ assert(state.match(/mCurrentFocus=.*\n/)?.[0].includes(`${appId}/`), "App must be focused");
+ const regions = state.match(/mLastStatusBarAppearanceRegions=\s*\n([^\n]+)/)?.[1];
+ assert(regions, "Android must report the status-bar appearance region");
+ return regions.includes("LIGHT_STATUS_BARS");
+}
+
+async function expectDarkIcons(expected, context) {
+ const deadline = Date.now() + 5_000;
+ do {
+ if (windowState() === expected) {
+ console.log(`PASS: ${context}`);
+ return;
+ }
+ await delay(100);
+ } while (Date.now() < deadline);
+ assert.equal(windowState(), expected, context);
+}
+
+function selectTheme(theme) {
+ const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
+ assert(current, "Start on Settings > Appearance");
+ ad("press", `label="Theme: ${current}"`, "--settle");
+ ad("press", `label="${theme}"`, "--settle");
+ ad("wait", "text", `Theme: ${theme}`, "5000");
+}
+
+ad("settings", "appearance", "light");
+selectTheme("Light");
+await expectDarkIcons(true, "Light app theme uses dark status icons");
+selectTheme("Dark");
+await expectDarkIcons(false, "Dark app theme uses light status icons");
+selectTheme("System");
+await expectDarkIcons(true, "System theme starts with dark icons on light OS appearance");
+ad("settings", "appearance", "dark");
+await expectDarkIcons(
+ false,
+ "System theme follows an OS change to dark without reopening settings",
+);
+ad("settings", "appearance", "light");
+await expectDarkIcons(true, "System theme follows an OS change back to light");
diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts
index 33308ee386..9608a95a0c 100644
--- a/packages/app/e2e/support/creation-fixtures.ts
+++ b/packages/app/e2e/support/creation-fixtures.ts
@@ -13,6 +13,7 @@ import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{
startup: { release(): Promise; fail(): Promise };
+ promptRejection: void;
creation: Awaited>;
promptRetry: ReturnType;
agentRetries: Awaited>;
@@ -65,6 +66,37 @@ export default function contribute(server) {
await client.close();
}
},
+ promptRejection: async ({ page }, provide) => {
+ void page;
+ const client = await connectNewWorkspaceDaemonClient();
+ const plugin = await copyPluginExample("lifecycle-logger");
+ const previous = await client.getDaemonConfig();
+ try {
+ await writeFile(
+ path.join(plugin.directory, "index.server.ts"),
+ `
+export default function contribute(server) {
+ return server.before("agent.create", ({ request }) => ({
+ ...request,
+ config: {
+ ...request.config,
+ model: "e2e-fast-stream",
+ featureValues: { ...request.config.featureValues, mockPromptRejections: 1 },
+ },
+ }));
+}
+`,
+ );
+ await client.patchDaemonConfig({ pluginsEnabled: true });
+ await client.installDirectoryPlugin(plugin.directory);
+ await provide();
+ } finally {
+ await client.removePlugin("lifecycle-logger");
+ await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
+ await plugin.cleanup();
+ await client.close();
+ }
+ },
creation: async ({ page }, provide) => {
const creation = await createCreationScenario(page);
try {
diff --git a/packages/app/e2e/support/helpers/acp-streaming-markdown.ts b/packages/app/e2e/support/helpers/acp-streaming-markdown.ts
index 0ebe9c2b3d..d7ca2ee183 100644
--- a/packages/app/e2e/support/helpers/acp-streaming-markdown.ts
+++ b/packages/app/e2e/support/helpers/acp-streaming-markdown.ts
@@ -52,13 +52,11 @@ export async function expectAcpMarkdown(page: Page, count: number): Promise {
+ for (let index = 0; index < count; index += 1) await page.keyboard.press(key);
+}
+
+export async function expectAddProjectResultVisible(page: Page, text: string): Promise {
+ 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);
+}
diff --git a/packages/app/e2e/support/helpers/agent-message-gate.ts b/packages/app/e2e/support/helpers/agent-message-gate.ts
index 7444fee796..9f5735d5ac 100644
--- a/packages/app/e2e/support/helpers/agent-message-gate.ts
+++ b/packages/app/e2e/support/helpers/agent-message-gate.ts
@@ -6,6 +6,8 @@ type WebSocketMessage = string | Buffer;
interface SendAgentMessageRequest {
type: "send_agent_message_request";
requestId: string;
+ text: unknown;
+ attachments: unknown;
agentId: string;
/** Absent when the client sends into an idle agent. */
activeTurnBehavior?: string;
@@ -29,6 +31,8 @@ function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | n
return {
type: "send_agent_message_request",
requestId: request.requestId,
+ text: request.text,
+ attachments: request.attachments,
agentId: request.agentId,
activeTurnBehavior:
typeof request.activeTurnBehavior === "string" ? request.activeTurnBehavior : undefined,
@@ -74,6 +78,7 @@ export async function gateNextAgentMessage(page: Page) {
return {
waitForRequest,
+ requestCount: () => requests.length,
accept(index = 0) {
const heldMessage = heldMessages[index];
if (!serverSocket || !heldMessage) {
diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts
index dd50ccfa3d..408bd8af24 100644
--- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts
+++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts
@@ -575,20 +575,25 @@ export async function holdAssistantStream(page: Page, agentId: string) {
});
});
+ const forwardUntil = async (done: (text: string) => boolean): Promise => {
+ while (!done(forwardedText)) {
+ const frame = pending.shift();
+ if (!frame) {
+ await new Promise((resolve) => {
+ notifyFrame = resolve;
+ });
+ continue;
+ }
+ forwardedText += frame.text;
+ frame.forward();
+ }
+ };
+
return {
waitForInitialTimeline: () => initialTimeline,
+ showUntil: (text: string) => forwardUntil((forwarded) => forwarded.includes(text)),
async showThrough(prefix: string): Promise {
- while (forwardedText.length < prefix.length) {
- const frame = pending.shift();
- if (!frame) {
- await new Promise((resolve) => {
- notifyFrame = resolve;
- });
- continue;
- }
- forwardedText += frame.text;
- frame.forward();
- }
+ await forwardUntil((text) => text.length >= prefix.length);
if (forwardedText !== prefix) {
throw new Error(
`Stream did not stop at ${JSON.stringify(prefix)}: ${JSON.stringify(forwardedText)}`,
diff --git a/packages/app/e2e/support/helpers/assistant-images.ts b/packages/app/e2e/support/helpers/assistant-images.ts
index 63bc182eaf..3d930b41a6 100644
--- a/packages/app/e2e/support/helpers/assistant-images.ts
+++ b/packages/app/e2e/support/helpers/assistant-images.ts
@@ -26,16 +26,13 @@ export interface AssistantImageFixture {
export async function createSmallAssistantPng(
workspace: SeededWorkspace,
- input: { alt: string; fileName: string },
+ input: { alt: string; fileName: string; width?: number; height?: number },
): Promise {
- await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG);
- return {
- alt: input.alt,
- height: 1,
- relativePath: input.fileName,
- size: SMALL_PNG.byteLength,
- width: 1,
- };
+ const width = input.width ?? 1;
+ const height = input.height ?? 1;
+ const bytes = width === 1 && height === 1 ? SMALL_PNG : encodeDeterministicPng(width, height);
+ await writeFile(path.join(workspace.repoPath, input.fileName), bytes);
+ return { alt: input.alt, height, relativePath: input.fileName, size: bytes.byteLength, width };
}
export async function createNearTenMegabyteAssistantPng(
@@ -61,6 +58,7 @@ export async function createNearTenMegabyteAssistantPng(
export async function createSettledMockAgent(
workspace: SeededWorkspace,
title: string,
+ markdown?: string,
): Promise {
const agent = await workspace.client.createAgent({
provider: "mock",
@@ -69,6 +67,7 @@ export async function createSettledMockAgent(
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
+ featureValues: markdown === undefined ? undefined : { mockAssistantResponse: markdown },
});
await workspace.client.waitForAgentUpsert(
agent.id,
@@ -87,10 +86,11 @@ export async function emitSettledAssistantImage(
client: SeedDaemonClient,
agent: ArchiveTabAgent,
image: AssistantImageFixture,
+ destination: string = image.relativePath,
): Promise {
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) {
@@ -123,7 +123,7 @@ export async function expectAssistantImageRendered(
page: Page,
image: AssistantImageFixture,
): Promise {
- const rendered = page.getByRole("img", { name: image.alt }).first();
+ const rendered = page.getByRole("button", { name: image.alt }).first();
await expect(rendered).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () =>
@@ -171,14 +171,14 @@ export async function expectAssistantImageNotMounted(
page: Page,
image: AssistantImageFixture,
): Promise {
- await expect(page.getByRole("img", { name: image.alt })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: image.alt })).toHaveCount(0);
}
export async function userPagesUntilAssistantImageRenders(
page: Page,
image: AssistantImageFixture,
): Promise {
- const rendered = page.getByRole("img", { name: image.alt });
+ const rendered = page.getByRole("button", { name: image.alt });
for (let attempt = 0; attempt < 10; attempt += 1) {
if ((await rendered.count()) > 0) {
await expectAssistantImageRendered(page, image);
@@ -252,8 +252,8 @@ async function beginVisibleImageStabilityObservation(
);
const imageTab = document.querySelector(`[data-testid="${tabTestId}"]`);
if (imageTab?.getAttribute("aria-selected") !== "true") return;
- const imageVisible = Array.from(document.querySelectorAll('[role="img"]')).some(
- (element) => element.getAttribute("aria-label") === accessibleName && isVisible(element),
+ const imageVisible = Array.from(document.querySelectorAll("img[alt]")).some(
+ (element) => element.getAttribute("alt") === accessibleName && isVisible(element),
);
record.missingSeen ||= !imageVisible;
};
diff --git a/packages/app/e2e/support/helpers/codex-workspace-restore.ts b/packages/app/e2e/support/helpers/codex-workspace-restore.ts
index 7595b4eb20..45bdc1aded 100644
--- a/packages/app/e2e/support/helpers/codex-workspace-restore.ts
+++ b/packages/app/e2e/support/helpers/codex-workspace-restore.ts
@@ -98,7 +98,6 @@ async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, i
settings: { modeId: "full-access", thinkingOptionId: "low" },
title: "Codex worktree restore reproduction",
initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`,
- background: true,
}),
);
agentId = agent.agentId;
diff --git a/packages/app/e2e/support/helpers/compact-workspace-scripts.ts b/packages/app/e2e/support/helpers/compact-workspace-scripts.ts
new file mode 100644
index 0000000000..b01a527bc6
--- /dev/null
+++ b/packages/app/e2e/support/helpers/compact-workspace-scripts.ts
@@ -0,0 +1,61 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+
+/** The compact ⋯ sheet. Its trigger shares "Workspace actions" with the sidebar row kebabs. */
+export function compactWorkspaceMenu(page: Page): Locator {
+ return page.getByTestId("workspace-header-menu-content");
+}
+
+export async function openCompactWorkspaceMenu(page: Page): Promise {
+ await page.getByTestId("workspace-header-menu-trigger").click();
+ await expect(compactWorkspaceMenu(page)).toBeVisible({ timeout: 10_000 });
+}
+
+export function menuRow(page: Page, name: string | RegExp): Locator {
+ return compactWorkspaceMenu(page).getByRole("menuitem", { name });
+}
+
+/** The root row that leads to the Scripts page; its accessible name carries its value. */
+export function scriptsMenuRow(page: Page): Locator {
+ return menuRow(page, /^Scripts/);
+}
+
+/** A row on the Scripts page. A running script's name is followed by "Running". */
+export function scriptRow(page: Page, scriptName: string): Locator {
+ return menuRow(page, new RegExp(`^${scriptName}`));
+}
+
+export async function openScriptsPage(page: Page): Promise {
+ await openCompactWorkspaceMenu(page);
+ await scriptsMenuRow(page).click();
+ await expect(scriptsMenuRow(page)).toHaveCount(0);
+ await expect(backButton(page)).toBeVisible();
+}
+
+export async function openRunningScriptPage(page: Page, scriptName: string): Promise {
+ await openScriptsPage(page);
+ await scriptRow(page, scriptName).click();
+ await expect(menuRow(page, "Stop")).toBeVisible();
+}
+
+/** Taps a stopped script on the Scripts page, which runs it and closes the sheet. */
+export async function runScriptFromScriptsPage(page: Page, scriptName: string): Promise {
+ await openScriptsPage(page);
+ await scriptRow(page, scriptName).click();
+ await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
+}
+
+export async function goBackInMenu(page: Page): Promise {
+ await backButton(page).click();
+}
+
+export async function closeCompactWorkspaceMenu(page: Page): Promise {
+ await page
+ .getByRole("button", { name: "Bottom sheet backdrop" })
+ .first()
+ .click({ position: { x: 12, y: 12 } });
+ await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
+}
+
+function backButton(page: Page): Locator {
+ return compactWorkspaceMenu(page).getByRole("button", { name: "Back" });
+}
diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts
index de8ddf0c9b..d4ef8fc2f4 100644
--- a/packages/app/e2e/support/helpers/composer.ts
+++ b/packages/app/e2e/support/helpers/composer.ts
@@ -56,6 +56,12 @@ export async function expectComposerNotFocused(page: Page): Promise {
await expect(composerInput(page)).not.toBeFocused();
}
+/** Blur the editor without anything else taking focus. */
+export async function blurComposerInput(page: Page): Promise {
+ await composerInput(page).blur();
+ await expectComposerNotFocused(page);
+}
+
export async function submitMessage(page: Page, text: string): Promise {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
@@ -316,3 +322,182 @@ export async function openGithubWorkspace(
},
};
}
+
+const COMPOSER_TEST_IMAGE_BASE64 =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
+
+export async function attachComposerTestImage(page: Page): Promise {
+ await attachImageFromMenu(page, {
+ name: "fullscreen.png",
+ mimeType: "image/png",
+ buffer: Buffer.from(COMPOSER_TEST_IMAGE_BASE64, "base64"),
+ });
+}
+
+export async function pasteComposerTestImage(page: Page): Promise {
+ return composerInput(page).evaluate((input, base64) => {
+ const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
+ const clipboardData = new DataTransfer();
+ clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
+ const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
+ input.dispatchEvent(event);
+ return event.defaultPrevented;
+ }, COMPOSER_TEST_IMAGE_BASE64);
+}
+
+export async function enterComposerFullscreen(page: Page): Promise {
+ await page.getByRole("button", { name: "Fullscreen", exact: true }).click();
+}
+export async function exitComposerFullscreen(page: Page): Promise {
+ await page.getByRole("button", { name: "Exit fullscreen", exact: true }).click();
+}
+export async function resizeComposerViewport(
+ page: Page,
+ orientation: "portrait" | "landscape" | "short",
+): Promise {
+ const sizes = {
+ portrait: { width: 390, height: 844 },
+ landscape: { width: 844, height: 390 },
+ 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,
+ start: number,
+ end: number,
+): Promise {
+ await composerInput(page).focus();
+ await composerInput(page).evaluate(
+ (element, selection) => {
+ const input = element as HTMLTextAreaElement;
+ input.setSelectionRange(selection.start, selection.end);
+ input.dispatchEvent(new Event("select", { bubbles: true }));
+ },
+ { start, end },
+ );
+}
+export async function expectComposerDraftSelection(
+ page: Page,
+ start: number,
+ end: number,
+): Promise {
+ await expect
+ .poll(() =>
+ composerInput(page).evaluate((element) => {
+ const input = element as HTMLTextAreaElement;
+ return [input.selectionStart, input.selectionEnd];
+ }),
+ )
+ .toEqual([start, end]);
+}
+export async function expectComposerFullscreenControls(page: Page): Promise {
+ await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
+ 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 {
+ 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 {
+ await page.getByRole("button", { name: "Send message", exact: true }).click();
+}
+export async function queueComposerWithButton(page: Page): Promise {
+ await page.getByRole("button", { name: "Queue message", exact: true }).click();
+}
+export async function configureComposerQueueSend(page: Page): Promise {
+ await page.addInitScript(() =>
+ localStorage.setItem("@paseo:app-settings", JSON.stringify({ sendBehavior: "queue" })),
+ );
+}
+
+/** 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 {
+ await page.keyboard.press(
+ direction === "previous" ? "Alt+Shift+BracketLeft" : "Alt+Shift+BracketRight",
+ );
+}
+export async function expectWorkspaceHeaderHidden(page: Page): Promise {
+ await expect(page.getByTestId("workspace-tab-switcher-trigger")).toHaveCount(0);
+}
+export async function expectWorkspaceHeaderVisible(page: Page): Promise {
+ await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
+}
+
+export async function activateRetainedComposerTab(page: Page, title: string): Promise {
+ await page
+ .getByTestId("workspace-tab-switcher-trigger")
+ .evaluate((element) => (element as HTMLElement).click());
+ const option = page.getByText(title, { exact: true }).filter({ visible: true }).last();
+ await expect(option).toBeVisible();
+ await option.evaluate((element) => (element as HTMLElement).click());
+}
+
+export async function recordVisibleComposerGeometry(page: Page): Promise {
+ await page.evaluate(() => {
+ const frames: { input: number; root: number }[] = [];
+ Reflect.set(window, "__composerGeometryFrames", frames);
+ let running = true;
+ const tick = () => {
+ if (!running) return;
+ for (const root of document.querySelectorAll('[data-testid="message-input-root"]')) {
+ if (root.getClientRects().length === 0) continue;
+ const input = root.querySelector("textarea");
+ if (input)
+ frames.push({
+ input: input.getBoundingClientRect().height,
+ root: root.getBoundingClientRect().height,
+ });
+ }
+ requestAnimationFrame(tick);
+ };
+ requestAnimationFrame(tick);
+ Reflect.set(window, "__stopComposerGeometry", () => {
+ running = false;
+ });
+ });
+}
+export async function expectNoCollapsedVisibleComposer(page: Page): Promise {
+ const frames = await page.evaluate(() => {
+ Reflect.get(window, "__stopComposerGeometry")?.();
+ return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
+ });
+ expect(frames.length).toBeGreaterThan(0);
+ expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
+}
diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts
index 09cd2ea290..9098068698 100644
--- a/packages/app/e2e/support/helpers/creation.ts
+++ b/packages/app/e2e/support/helpers/creation.ts
@@ -182,6 +182,47 @@ export async function createCreationScenario(page: Page) {
await this.expectWorkspaceReadyBeforeAgentCompletion();
expect((await project.client.fetchAgents()).entries).toHaveLength(0);
},
+ async expectCreatedAgentError() {
+ await requests.settled();
+ await expect(
+ page
+ .getByTestId("assistant-message")
+ .filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }),
+ ).toBeVisible();
+ await expect(
+ page
+ .locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]')
+ .filter({ visible: true }),
+ ).toBeVisible();
+ await expect(
+ page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }),
+ ).toHaveCount(0);
+ await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEditable();
+ await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
+ },
+ async expectPromptBeforeError(prompt: string) {
+ const messages = page
+ .getByTestId("user-message")
+ .or(page.getByTestId("assistant-message"))
+ .filter({ visible: true });
+ await expect(messages).toHaveCount(2);
+ await expect(messages.nth(0)).toContainText(prompt);
+ await expect(messages.nth(1)).toContainText("[System Error]");
+ await expect(messages.nth(1)).toBeInViewport();
+ },
+ async reloadAgent() {
+ await page.reload();
+ await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0);
+ await this.expectCreatedAgentError();
+ },
+ async expectAssistantReply() {
+ await expect(
+ page
+ .getByTestId("assistant-message")
+ .filter({ visible: true, hasNotText: "[System Error]" })
+ .first(),
+ ).toBeVisible();
+ },
async expectStartupFailure() {
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
},
diff --git a/packages/app/e2e/support/helpers/custom-models.ts b/packages/app/e2e/support/helpers/custom-models.ts
new file mode 100644
index 0000000000..11933a0dfa
--- /dev/null
+++ b/packages/app/e2e/support/helpers/custom-models.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ await page.getByRole("textbox", { name: "Search models" }).fill(id);
+ await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
+ ratio: 1,
+ });
+}
diff --git a/packages/app/e2e/support/helpers/file-explorer.ts b/packages/app/e2e/support/helpers/file-explorer.ts
index 519f955536..a40adc5ad2 100644
--- a/packages/app/e2e/support/helpers/file-explorer.ts
+++ b/packages/app/e2e/support/helpers/file-explorer.ts
@@ -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 {
+ await fileExplorerEntry(page, fileName).click({ button: "right" });
+ const downloaded = page.waitForEvent("download");
+ await page.getByRole("menuitem", { name: "Download", exact: true }).click();
+ return downloaded;
+}
diff --git a/packages/app/e2e/support/helpers/pair-device.ts b/packages/app/e2e/support/helpers/pair-device.ts
index d8908015b8..46cdda5ed9 100644
--- a/packages/app/e2e/support/helpers/pair-device.ts
+++ b/packages/app/e2e/support/helpers/pair-device.ts
@@ -220,3 +220,24 @@ export function expectDaemonPidUnchanged(
expect(before).toBeDefined();
expect(after).toBe(before);
}
+
+export async function expectCompletePairingQr(page: Page): Promise {
+ const qr = page.getByRole("img", { name: "Pairing QR code" });
+ await qr.scrollIntoViewIfNeeded();
+ // IntersectionObserver rounds fractional sheet/scroll coordinates.
+ await expect(qr).toBeInViewport({ ratio: 0.999 });
+ const bounds = await qr.evaluate((element) => {
+ const tile = element.parentElement;
+ if (!tile) throw new Error("Pairing QR tile is missing");
+ const qrRect = element.getBoundingClientRect();
+ const tileRect = tile.getBoundingClientRect();
+ return {
+ qr: { x: qrRect.x, y: qrRect.y, right: qrRect.right, bottom: qrRect.bottom },
+ tile: { x: tileRect.x, y: tileRect.y, right: tileRect.right, bottom: tileRect.bottom },
+ };
+ });
+ expect(bounds.qr.x).toBeGreaterThanOrEqual(bounds.tile.x);
+ expect(bounds.qr.y).toBeGreaterThanOrEqual(bounds.tile.y);
+ expect(bounds.qr.right).toBeLessThanOrEqual(bounds.tile.right);
+ expect(bounds.qr.bottom).toBeLessThanOrEqual(bounds.tile.bottom);
+}
diff --git a/packages/app/e2e/support/helpers/plugin-button-example.ts b/packages/app/e2e/support/helpers/plugin-button-example.ts
index 94018c4415..ebe868ee2c 100644
--- a/packages/app/e2e/support/helpers/plugin-button-example.ts
+++ b/packages/app/e2e/support/helpers/plugin-button-example.ts
@@ -30,7 +30,6 @@ async function capture(
name: string,
subject: Locator = headerButtons(page),
) {
- await expect(headerButtons(page)).toHaveCount(1);
await expect(subject).toBeInViewport();
await waitForSettledPosition(subject);
const file = info.outputPath(`${name}.png`);
@@ -80,6 +79,7 @@ export async function withButtonAuthorExample(
refreshFromHeaderAndComposer: () =>
test.step("icon-only header action and labeled composer action update in place", async () => {
await exampleCommand(page, "action");
+ await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click();
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
@@ -88,7 +88,13 @@ export async function withButtonAuthorExample(
).toContainText("Refreshed · 2");
await capture(page, info, "01-actions");
await page.setViewportSize(COMPACT);
- await capture(page, info, "02-compact-actions");
+ await expect(headerButtons(page)).toHaveCount(0);
+ await capture(
+ page,
+ info,
+ "02-compact-actions",
+ page.getByRole("button", { name: "Refresh context", exact: true }),
+ );
}),
useMenusAndToggleButtons: () =>
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
@@ -128,7 +134,8 @@ export async function withButtonAuthorExample(
await capture(page, info, "05-disabled");
await exampleCommand(page, "enable");
await page.setViewportSize(COMPACT);
- await headerButtons(page).click();
+ await expect(headerButtons(page)).toHaveCount(0);
+ await page.getByRole("button", { name: "Composer tools", exact: true }).click();
await expect(
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
).toBeInViewport();
@@ -143,6 +150,7 @@ export async function withButtonAuthorExample(
inspectLiveWorkspaceDetails: () =>
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
await exampleCommand(page, "popover");
+ await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click();
await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }),
diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts
index 7404173fc8..f3279ce7ef 100644
--- a/packages/app/e2e/support/helpers/plugin-buttons.ts
+++ b/packages/app/e2e/support/helpers/plugin-buttons.ts
@@ -202,31 +202,6 @@ async function expectHeaderTextTruncated(page: Page) {
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
}
-async function holdButton(page: Page, button: Locator) {
- await button.hover();
- await page.mouse.down();
-}
-
-async function openCompactOverflowWithHeaderChrome(page: Page) {
- const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
- const action = page.getByRole("button", { name: "Deploy application", exact: true });
- const overflow = page
- .getByRole("toolbar", { name: "Workspace actions", exact: true })
- .getByRole("button", { name: "More actions", exact: true });
- await expect(action).toHaveCSS("border-top-width", "0px");
- await expect(overflow).toHaveCSS("border-top-width", "0px");
- await hamburger.hover();
- const highlight = await hamburger.evaluate(
- (element) => getComputedStyle(element).backgroundColor,
- );
- await action.hover();
- await expect(action).toHaveCSS("background-color", highlight);
- await holdButton(page, overflow);
- await expect(overflow).toHaveCSS("background-color", highlight);
- await expectSurfaceVisible(page);
- await page.mouse.up();
-}
-
export async function withButtonShowcase(
page: Page,
run: (buttons: {
@@ -408,7 +383,7 @@ export async function withButtonShowcase(
);
}),
openCompactSheets: () =>
- test.step("compact triggers and overflow open sheets with working plugin context", async () => {
+ test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
@@ -426,39 +401,12 @@ export async function withButtonShowcase(
"Button showcase with a long workspace title",
);
await expectHeaderTextTruncated(page);
- await expectSurfaceVisible(page);
- await openCompactOverflowWithHeaderChrome(page);
await expect(
- page.getByRole("menuitem", { name: "Production locked", exact: true }),
- ).toBeDisabled();
- await expectSurfaceVisible(
- page,
- page.getByRole("menuitem", { name: "Production locked", exact: true }),
- );
- await choose(page, "Header checks");
- await expectSurfaceVisible(
- page,
- page.getByRole("menuitem", { name: "Run checks", exact: true }),
- );
- await choose(page, "Deployment details");
- await expectDetails(page);
- await expectSurfaceVisible(
- page,
- page.getByRole("button", { name: "Close deployment details", exact: true }),
- );
- await press(page, "Close deployment details");
- await showcase.setTitle("Compact checks");
- await press(page, "Header checks");
- await expect(
- page
- .getByRole("button", { name: "Header checks", exact: true })
- .getByTestId("plugin-button-chevron"),
+ page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
).toHaveCount(0);
- await expectSurfaceVisible(
- page,
- page.getByRole("menuitem", { name: "Run checks", exact: true }),
- );
- await choose(page, "Run checks");
+ await expect(
+ page.getByRole("button", { name: "Open menu", exact: true }),
+ ).toBeInViewport();
await showcase.setTitle("Button showcase");
await press(page, "Composer status");
await expectDetails(page);
diff --git a/packages/app/e2e/support/helpers/plugin-timeline.ts b/packages/app/e2e/support/helpers/plugin-timeline.ts
index d6656f3d3a..8b0f680e2d 100644
--- a/packages/app/e2e/support/helpers/plugin-timeline.ts
+++ b/packages/app/e2e/support/helpers/plugin-timeline.ts
@@ -47,6 +47,65 @@ export default function contribute(client) {
return () => {};
}`;
+const PROMPT_PLUGIN_ID = "timeline-prompt-e2e";
+
+function promptClientSource(prompts?: readonly string[]): string {
+ return `import React from "react";
+import { Text, View } from "react-native";
+import { z } from "zod";
+
+const selectedPrompts = ${JSON.stringify(prompts ?? null)};
+
+function PromptCard({ item }) {
+ return {item.data.text} ;
+}
+
+export default function contribute(client) {
+ client.addTimelineTransformer({
+ id: "prompt",
+ query: { itemType: "user_message" },
+ transform({ item }) {
+ if (selectedPrompts && !selectedPrompts.includes(item.text)) return undefined;
+ return { items: [{ type: "plugin", kind: "prompt", version: 1, data: { text: item.text } }] };
+ },
+ });
+ client.addTimelineRenderer({
+ kind: "prompt", version: 1,
+ schema: z.object({ text: z.string() }),
+ Component: PromptCard,
+ });
+ return () => {};
+}`;
+}
+
+async function withDirectoryPlugin(options: {
+ pluginId: string;
+ source: string;
+ run: () => Promise;
+}): Promise {
+ const { pluginId, source, run } = options;
+ const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
+ const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
+ const previous = await pluginClient.getDaemonConfig();
+ try {
+ await writeFile(
+ path.join(directory, "paseo-plugin.json"),
+ JSON.stringify({ id: pluginId, requirements: pluginRequirements }),
+ );
+ await writeFile(path.join(directory, "index.client.tsx"), source);
+ await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
+ await pluginClient.installDirectoryPlugin(directory);
+ await run();
+ } finally {
+ await pluginClient.removePlugin(pluginId);
+ await pluginClient.patchDaemonConfig({
+ pluginsEnabled: previous.config.pluginsEnabled ?? false,
+ });
+ await pluginClient.close();
+ await rm(directory, { recursive: true, force: true });
+ }
+}
+
export async function withTimelinePlugin(
page: Page,
info: TestInfo,
@@ -60,7 +119,6 @@ export async function withTimelinePlugin(
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
- const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-plugin-",
title: "Timeline plugin regression",
@@ -73,40 +131,53 @@ export async function withTimelinePlugin(
}
: {},
});
- const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
- const previous = await pluginClient.getDaemonConfig();
try {
- await writeFile(
- path.join(directory, "paseo-plugin.json"),
- JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
- );
- await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE);
- await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
- await pluginClient.installDirectoryPlugin(directory);
- await openAgentRoute(page, agent);
- await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
- await run(agent);
- await info.attach("timeline-plugin", {
- body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
- contentType: "image/png",
+ await withDirectoryPlugin({
+ pluginId: PLUGIN_ID,
+ source: CLIENT_SOURCE,
+ run: async () => {
+ try {
+ await openAgentRoute(page, agent);
+ await expect(
+ page.getByRole("textbox", { name: "Message agent..." }).first(),
+ ).toBeVisible();
+ await run(agent);
+ await info.attach("timeline-plugin", {
+ body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
+ contentType: "image/png",
+ });
+ } catch (error) {
+ await info.attach("timeline-before-cleanup", {
+ body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
+ contentType: "image/png",
+ });
+ throw error;
+ }
+ },
});
- } catch (error) {
- await info.attach("timeline-before-cleanup", {
- body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
- contentType: "image/png",
- });
- throw error;
} finally {
- await pluginClient.removePlugin(PLUGIN_ID);
- await pluginClient.patchDaemonConfig({
- pluginsEnabled: previous.config.pluginsEnabled ?? false,
- });
- await pluginClient.close();
await agent.cleanup();
- await rm(directory, { recursive: true, force: true });
}
}
+/** Draws all prompts, or only the selected prompts, as plugin cards. */
+export async function withPromptTimelinePlugin(
+ run: () => Promise,
+ prompts?: readonly string[],
+): Promise {
+ await withDirectoryPlugin({
+ pluginId: PROMPT_PLUGIN_ID,
+ source: promptClientSource(prompts),
+ run,
+ });
+}
+
+export async function expectPromptsDrawnByPlugin(page: Page): Promise {
+ const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
+ await expect(timeline.getByTestId("plugin-prompt").first()).toBeVisible({ timeout: 30_000 });
+ await expect(timeline.getByTestId("user-message")).toHaveCount(0);
+}
+
export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise {
await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms.");
}
@@ -142,3 +213,64 @@ export async function expectBothConsecutiveTools(page: Page): Promise {
await grep.scrollIntoViewIfNeeded();
await expect(grep).toBeVisible();
}
+
+/** The row belongs to the virtualized history, not the always-mounted recent window. */
+export async function expectVirtualizedPluginPromptVisible(
+ page: Page,
+ prompt: string,
+): Promise {
+ const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
+ await expect(
+ timeline.locator("[data-index]").getByTestId("plugin-prompt").filter({ hasText: prompt }),
+ ).toBeVisible();
+}
+
+export async function attachPromptTimelineEvidence(page: Page, info: TestInfo): Promise {
+ await info.attach("plugin-outline-jump", {
+ body: await page.screenshot({ path: info.outputPath("plugin-outline-jump.png") }),
+ contentType: "image/png",
+ });
+}
+
+const POPOVER_CLIENT_SOURCE = `import React, { useEffect, useRef } from "react";
+import { z } from "zod";
+
+function Preview() {
+ const panel = useRef(null);
+ useEffect(() => {
+ const close = (event) => {
+ if (event.key === "Escape" && panel.current.matches(":popover-open")) {
+ panel.current.hidePopover();
+ }
+ };
+ document.addEventListener("keydown", close, true);
+ return () => document.removeEventListener("keydown", close, true);
+ }, []);
+ return
+
+ Plugin hover preview
+ ;
+}
+
+export default function contribute(client) {
+ client.addTimelineTransformer({
+ id: "preview", query: { itemType: "user_message" },
+ transform({ item }) {
+ if (item.text !== "Show the plugin preview.") return;
+ return { items: [{ type: "plugin", kind: "preview", version: 1, data: {} }] };
+ },
+ });
+ client.addTimelineRenderer({
+ kind: "preview", version: 1, schema: z.object({}), Component: Preview,
+ });
+ return () => {};
+}`;
+
+/** Installs a timeline preview that owns Escape through the DOM Popover API. */
+export async function withPopoverTimelinePlugin(run: () => Promise): Promise {
+ await withDirectoryPlugin({
+ pluginId: "timeline-popover-e2e",
+ source: POPOVER_CLIENT_SOURCE,
+ run,
+ });
+}
diff --git a/packages/app/e2e/support/helpers/pr-pane.ts b/packages/app/e2e/support/helpers/pr-pane.ts
index 56f0ef444c..37fe719e0f 100644
--- a/packages/app/e2e/support/helpers/pr-pane.ts
+++ b/packages/app/e2e/support/helpers/pr-pane.ts
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test";
-import { getStateLabel } from "@/git/pull-request-panel/data";
+import { en } from "@/i18n/resources/en";
import { openPullRequestPanel } from "./workspace-tabs";
export async function openPrPane(page: Page): Promise {
@@ -14,7 +14,7 @@ export async function expectPrPaneState(
page: Page,
state: "open" | "merged" | "closed" | "draft",
): Promise {
- await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
+ await expect(page.getByTestId("pr-pane-state")).toHaveText(en.workspace.git.pr.states[state], {
timeout: 15_000,
});
}
diff --git a/packages/app/e2e/support/helpers/questions.ts b/packages/app/e2e/support/helpers/questions.ts
index ebe13aead5..a26bd9a975 100644
--- a/packages/app/e2e/support/helpers/questions.ts
+++ b/packages/app/e2e/support/helpers/questions.ts
@@ -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 {
+ 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 },
diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts
index 869e96b294..4aec3fff01 100644
--- a/packages/app/e2e/support/helpers/seed-client.ts
+++ b/packages/app/e2e/support/helpers/seed-client.ts
@@ -66,6 +66,7 @@ export interface SeedDaemonClient {
worktreeSlug?: string;
};
title?: string;
+ background?: boolean;
}): Promise<{
workspace: SeedWorkspaceDescriptor | null;
error: string | null;
@@ -223,6 +224,7 @@ export interface SeededWorkspace {
export async function seedWorkspace(options: {
repoPrefix: string;
title?: string;
+ background?: boolean;
port?: number;
/** Repo fixture options; only applies to git projects (the default). */
repo?: Parameters[1];
@@ -238,6 +240,7 @@ export async function seedWorkspace(options: {
const created = await client.createWorkspace({
source: { kind: "directory", path: project.path },
title: options.title,
+ background: options.background,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts
index 1702f7fe44..6b810e6d13 100644
--- a/packages/app/e2e/support/helpers/settings.ts
+++ b/packages/app/e2e/support/helpers/settings.ts
@@ -370,7 +370,7 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise {
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(
diff --git a/packages/app/e2e/support/helpers/sheet.ts b/packages/app/e2e/support/helpers/sheet.ts
new file mode 100644
index 0000000000..6288c6f9c1
--- /dev/null
+++ b/packages/app/e2e/support/helpers/sheet.ts
@@ -0,0 +1,99 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+
+/** 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 });
+}
+
+/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */
+export function sheetHeader(page: Page, label: string): Locator {
+ return page.getByTestId("sheet-tab-header").filter({ hasText: label });
+}
+
+/** Sheet height as a fraction of the viewport; mirrors the sheet tab's 92% snap point. */
+const SHEET_SNAP_FRACTION = 0.92;
+/** The header starts below the sheet's drag indicator, inside the handle. */
+const SHEET_INDICATOR_MAX_HEIGHT = 32;
+
+/**
+ * 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 expectSheetAtSnap(page: Page, label: string): Promise {
+ const viewportHeight = page.viewportSize()?.height ?? 0;
+ const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
+ const header = sheetHeader(page, label);
+ const body = sheet(page, label);
+ await expect
+ .poll(async () => {
+ 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(bodyBox.y + bodyBox.height) === viewportHeight,
+ topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_MAX_HEIGHT,
+ };
+ })
+ .toEqual({ dockedToBottom: true, topAtSnap: true });
+}
+
+export async function expectOpenTabCount(page: Page, count: number): Promise {
+ await expect(
+ page.getByRole("button", { name: `Switch tabs (${count} open)` }).filter({ visible: true }),
+ ).toBeVisible();
+}
+
+export async function closeSheet(page: Page, label: string): Promise {
+ await sheetHeader(page, label).getByRole("button", { name: "Close" }).click();
+ await expect(sheet(page, label)).toHaveCount(0);
+}
+
+export async function openSheetAsTab(page: Page, label: string): Promise {
+ await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
+ await expect(sheet(page, label)).toHaveCount(0);
+}
+
+export async function replaceSheetFileText(
+ page: Page,
+ label: string,
+ content: string,
+): Promise {
+ const editor = sheet(page, label).getByTestId("file-source-editor").locator(".cm-content");
+ await editor.click();
+ await editor.press("ControlOrMeta+A");
+ await editor.pressSequentially(content);
+}
+
+export async function selectTabFromSwitcher(page: Page, label: string): Promise {
+ await page
+ .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);
+}
+
+/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
+export async function tapSheetBackdrop(page: Page): Promise {
+ await page
+ .getByRole("button", { name: "Bottom sheet backdrop" })
+ .last()
+ .click({ position: { x: 10, y: 10 } });
+}
+
+/** Runs a close that hits the unsaved-changes guard, and declines the discard. */
+export async function expectDeclinedDiscardPrompt(
+ page: Page,
+ close: () => Promise,
+): Promise {
+ const prompt = page.waitForEvent("dialog").then(async (dialog) => {
+ const message = dialog.message();
+ await dialog.dismiss();
+ return message;
+ });
+ await close();
+ expect(await prompt).toContain("discard the draft");
+}
diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts
index b707abdae8..e3eff916e7 100644
--- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts
+++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts
@@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01;
* Settings together at the end.
*/
export async function expectFooterIconRow(page: Page): Promise {
- const boxes = (
- await Promise.all(
- FOOTER_ICON_TEST_IDS.map((testID) =>
- page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
- ),
- )
- ).map((box) => box!);
+ // Read one browser-frame snapshot: the compact drawer can translate between separate RPCs.
+ const boxes = await page
+ .locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(","))
+ .evaluateAll((elements) =>
+ elements.map((element) => {
+ const { x, y, width, height } = element.getBoundingClientRect();
+ return { testID: element.getAttribute("data-testid"), x, y, width, height };
+ }),
+ );
+ expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS);
const [first] = boxes;
for (const box of boxes) {
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts
index c2afc34ef8..500ce8bd41 100644
--- a/packages/app/e2e/support/helpers/sidebar.ts
+++ b/packages/app/e2e/support/helpers/sidebar.ts
@@ -53,6 +53,37 @@ export async function showWorkspaceHoverCard(page: Page, workspaceId: string): P
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
}
+export async function movePointerAwayFromWorkspaceHoverCard(page: Page): Promise {
+ // Two moves, so the pointer always moves even when it is already parked here.
+ await page.mouse.move(1200, 200, { steps: 5 });
+ await page.mouse.move(1200, 400, { steps: 5 });
+}
+
+export async function focusWorkspaceRowWithKeyboard(
+ page: Page,
+ workspaceId: string,
+): Promise {
+ const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
+ await row.focus();
+ await page.keyboard.press("Shift+Tab");
+ await page.keyboard.press("Tab");
+ await expect(row).toBeFocused();
+}
+
+export async function expectWorkspaceHoverCardOpen(page: Page): Promise {
+ await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
+}
+
+export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise {
+ // Longer than the hover card's close grace period.
+ await page.waitForTimeout(300);
+ await expectWorkspaceHoverCardOpen(page);
+}
+
+export async function expectWorkspaceHoverCardClosed(page: Page): Promise {
+ await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
+}
+
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise {
const workspaceKey = `${getServerId()}:${workspaceId}`;
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
@@ -219,3 +250,41 @@ export async function expectMobileAgentSidebarVisible(page: Page): Promise
export async function expectMobileAgentSidebarHidden(page: Page): Promise {
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 {
+ 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 {
+ 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 {
+ 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 {
+ await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 });
+}
+
+export async function expectMobileExplorerHidden(page: Page): Promise {
+ await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 });
+}
diff --git a/packages/app/e2e/support/helpers/streaming-markdown.ts b/packages/app/e2e/support/helpers/streaming-markdown.ts
index e628d80471..91bf9be172 100644
--- a/packages/app/e2e/support/helpers/streaming-markdown.ts
+++ b/packages/app/e2e/support/helpers/streaming-markdown.ts
@@ -45,11 +45,8 @@ export async function requestStreamingMarkdown(agent: StreamingMarkdownAgent): P
export async function expectUnfinishedBold(page: Page): Promise {
const message = page.getByTestId("assistant-message").last();
- const bold = message.locator('[data-paseo-markdown-tag="strong"]');
- await expect(bold).toContainText("Bold");
+ await expect(message.locator(".enrm-text")).toContainText("Bold");
await expect(message).not.toContainText("stays bold");
- await expect(bold).toHaveCSS("font-weight", "500");
- await expect(message).not.toContainText("*");
}
export async function expectUnfinishedLink(
@@ -61,7 +58,6 @@ export async function expectUnfinishedLink(
await agent.stream.showThrough("**Bold text stays bold** and [Paseo docs");
await expect(message).toContainText("Paseo docs");
await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0);
- await expect(message).not.toContainText("[");
await expect(message).not.toContainText("https:");
await captureMarkdown(page, testInfo, "unfinished-link");
}
@@ -95,8 +91,5 @@ async function expectCompletedMarkdown(page: Page): Promise {
await expect(
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
).toHaveAttribute("href", "https://example.com/documentation");
- await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveCSS(
- "font-weight",
- "500",
- );
+ await expect(message.locator("strong")).toHaveCSS("font-weight", "700");
}
diff --git a/packages/app/e2e/support/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts
index d9c59e702e..f3a5ca7c71 100644
--- a/packages/app/e2e/support/helpers/subagents.ts
+++ b/packages/app/e2e/support/helpers/subagents.ts
@@ -49,31 +49,37 @@ export async function seedParentWithSubagent(
modeId: "load-test",
model: "ten-second-stream",
});
- const child = await workspace.client.createAgent({
- provider: "mock",
- cwd: workspace.repoPath,
- workspaceId: workspace.workspaceId,
- title: input.childTitle,
- modeId: "load-test",
- model: "ten-second-stream",
- labels: {
- [PARENT_AGENT_ID_LABEL]: parent.id,
- },
- });
+ const child = await seedSubagent(workspace, { parentId: parent.id, title: input.childTitle });
return {
parent: {
id: parent.id,
title: input.parentTitle,
},
- child: {
- id: child.id,
- title: input.childTitle,
- },
+ child,
workspaceId: workspace.workspaceId,
};
}
+/** Seeds an idle Paseo subagent of an existing agent in the same workspace. */
+export async function seedSubagent(
+ workspace: Pick,
+ input: { parentId: string; title: string },
+): Promise<{ id: string; title: string }> {
+ const child = await workspace.client.createAgent({
+ provider: "mock",
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: input.title,
+ modeId: "load-test",
+ model: "ten-second-stream",
+ labels: {
+ [PARENT_AGENT_ID_LABEL]: input.parentId,
+ },
+ });
+ return { id: child.id, title: input.title };
+}
+
export async function seedParentWithCrossWorkspaceSubagent(
workspace: Pick,
input: { parentTitle: string; childTitle: string },
@@ -141,6 +147,12 @@ export async function openSubagentsTrack(page: Page): Promise {
await expect(panel).toBeVisible({ timeout: 30_000 });
}
+/** On compact the track opens as a sheet, which closes once a row is chosen. */
+export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise {
+ await page.getByTestId("subagents-track-header").click();
+ await page.getByTestId(`subagents-track-row-${childId}`).click();
+}
+
export async function expectSubagentRowVisible(page: Page, childId: string): Promise {
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
timeout: 30_000,
diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts
index 7dd3dca8d7..52027df956 100644
--- a/packages/app/e2e/support/helpers/timeline-pagination.ts
+++ b/packages/app/e2e/support/helpers/timeline-pagination.ts
@@ -544,7 +544,7 @@ async function readTimelineViewport(page: Page): Promise {
+export async function waitForTimelineGeometryToSettle(page: Page): Promise {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.evaluate(
(element) =>
diff --git a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts
index 01055ce2c1..41f8cf3279 100644
--- a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts
+++ b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts
@@ -1,8 +1,9 @@
import { writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { expect, type Page, type TestInfo } from "@playwright/test";
-import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
+import { seedMockAgentWorkspace, openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
import { openAgentTimeline, expectTimelinePromptVisible } from "./timeline-pagination";
+import { holdAssistantStream } from "./agent-timeline-gate";
export const scrollCadences = [
{ name: "slow", delta: 160, intervalMs: 50, steps: 1000 },
@@ -44,6 +45,120 @@ interface TimelinePage {
at: number;
}
+export async function withStreamingReader(run: (agent: MockAgentWorkspace) => Promise) {
+ const section = "Read this paragraph while the response continues below. ".repeat(8);
+ const response = [
+ "```mermaid\nflowchart LR\n Start --> Review --> Done\n```",
+ "```mermaid\nflowchart LR\n Nested --> Finished\n```",
+ ...Array.from({ length: 40 }, (_, index) => `## Long section ${index}\n\n${section}`),
+ "READER-STREAM-SETTLED",
+ ].join("\n\n");
+ const agent = await seedMockAgentWorkspace({
+ repoPrefix: "held-stream-reader-",
+ title: "Held streaming paragraph",
+ featureValues: {
+ mockStreamingAssistantResponse: response,
+ mockStreamingAssistantIntervalMs: 1,
+ },
+ });
+ try {
+ await run(agent);
+ } finally {
+ await agent.cleanup();
+ }
+}
+
+export async function holdParagraphThroughCompletion(page: Page, agent: MockAgentWorkspace) {
+ const stream = await holdAssistantStream(page, agent.agentId);
+ await openAgentRoute(page, agent);
+ await page.getByRole("textbox", { name: "Message agent...", exact: true }).waitFor();
+ const scroll = page.getByTestId("agent-chat-scroll");
+ const following = await scroll.evaluateHandle((viewport) => {
+ const result = { stopped: false, maxGap: 0 };
+ const frame = () => {
+ if (result.stopped) return;
+ result.maxGap = Math.max(
+ result.maxGap,
+ viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop,
+ );
+ requestAnimationFrame(frame);
+ };
+ requestAnimationFrame(frame);
+ return result;
+ });
+ await agent.client.sendAgentMessage(agent.agentId, "Stream the mixed response.");
+ await stream.showUntil("Long section 12\n");
+ await page
+ .getByRole("heading", { name: "Long section 12", exact: true })
+ .waitFor({ state: "attached" });
+ // Exercise a reader whose producer has already finished; delivery must be gated.
+ await agent.client.waitForFinish(agent.agentId, 30_000);
+ await expect(page.getByTestId("mermaid-viewport")).toHaveCount(2);
+ await expect(page.getByTestId("mermaid-viewport").nth(0)).toHaveCSS("opacity", "1");
+ await expect(page.getByTestId("mermaid-viewport").nth(1)).toHaveCSS("opacity", "1");
+ const maxFollowGap = await following.evaluate((result) => {
+ result.stopped = true;
+ return result.maxGap;
+ });
+ const bounds = await scroll.boundingBox();
+ if (!bounds) throw new Error("Reader viewport is missing");
+ await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
+ await page.mouse.wheel(0, -1600);
+ await page.waitForTimeout(150);
+ const paragraph = page.getByRole("heading", { name: "Long section 3", exact: true });
+ await paragraph.waitFor({ state: "attached" });
+ const recording = await paragraph.evaluateHandle((heading) => {
+ if (!document.querySelector('[data-testid="turn-working-indicator"]')) {
+ throw new Error("The stream completed before the reader detached");
+ }
+ const viewport = document.querySelector('[data-testid="agent-chat-scroll"]')!;
+ const clip = viewport.getBoundingClientRect();
+ viewport.scrollTop += heading.getBoundingClientRect().y - (clip.top + clip.height / 2);
+ const baseline = heading.getBoundingClientRect().y;
+ const initialTextLength = heading.closest('[data-testid="assistant-message"]')!.textContent!
+ .length;
+ const result = { maxDrift: 0, retained: true, textGrowth: 0, done: false };
+ let stableAt = performance.now();
+ let signature = "";
+ const frame = (now: number) => {
+ result.retained &&= heading.isConnected;
+ const current = [...document.querySelectorAll("h2")].find(
+ (n) => n.textContent === "Long section 3",
+ );
+ const textLength =
+ current?.closest('[data-testid="assistant-message"]')?.textContent?.length ?? 0;
+ result.textGrowth = Math.max(result.textGrowth, textLength - initialTextLength);
+ if (current)
+ result.maxDrift = Math.max(
+ result.maxDrift,
+ Math.abs(current.getBoundingClientRect().y - baseline),
+ );
+ const next = `${viewport.scrollHeight}:${viewport.scrollTop}:${current?.getBoundingClientRect().y}`;
+ if (next !== signature) {
+ signature = next;
+ stableAt = now;
+ }
+ const complete =
+ document.body.textContent?.includes("Worked for") &&
+ document.body.textContent.includes("READER-STREAM-SETTLED") &&
+ !document.querySelector('[data-testid="turn-working-indicator"]');
+ if (complete && now - stableAt >= 750) {
+ result.done = true;
+ return;
+ }
+ requestAnimationFrame(frame);
+ };
+ requestAnimationFrame(frame);
+ return result;
+ });
+ await expect(
+ page.getByTestId("scroll-to-bottom-button").filter({ visible: true }).first(),
+ ).toBeVisible();
+ stream.release();
+ await page.waitForFunction((result) => result.done, recording);
+ return { ...(await recording.jsonValue()), maxFollowGap };
+}
+
/** Real HTTP image responses, with intrinsic dimensions unavailable until the response arrives. */
export async function withVariedTimeline(
run: (agent: MockAgentWorkspace, newestPrompt: string) => Promise,
@@ -297,11 +412,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 +427,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,
@@ -491,7 +610,7 @@ export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo):
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, "Show the delayed image");
- const image = page.getByRole("img", { name: "Reserved image" }).first();
+ const image = page.getByRole("button", { name: "Reserved image", exact: true }).first();
await expect(image).toBeVisible();
const before = await image.boundingBox();
await attachTimelineScreenshot(page, testInfo, "image-placeholder");
@@ -508,8 +627,9 @@ export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo):
.toBe(240);
await expect
.poll(async () => {
- const box = await image.boundingBox();
- return box ? Math.round((box.width / box.height) * 100) : 0;
+ return image.evaluate((element: HTMLImageElement) =>
+ Math.round((element.naturalWidth / element.naturalHeight) * 100),
+ );
})
.toBe(150);
const after = await image.boundingBox();
diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts
index 985e7bd7ac..6129f0259e 100644
--- a/packages/app/e2e/support/helpers/usage-login.ts
+++ b/packages/app/e2e/support/helpers/usage-login.ts
@@ -58,10 +58,6 @@ export default function contribute(server) {
}
}
-export async function openUsage(page: Page) {
- await page.goto("/usage");
-}
-
export async function refreshLoginUsage(page: Page) {
await page.getByRole("button", { name: "Refresh Claude", exact: true }).click();
}
diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts
index d21473867e..95c6a4d689 100644
--- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts
+++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts
@@ -8,7 +8,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
import { waitForSettledPosition } from "./sheet-layout";
-/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
+/** Real usage-source plugin; its long report makes the Usage modal scroll. */
export async function installTallUsageSource() {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
@@ -142,13 +142,9 @@ export function usageItem(page: Page): Locator {
return visible(page, "sidebar-usage");
}
-/** The compact usage sheet the Usage item opens. */
-export function usageSheet(page: Page): Locator {
- return visible(page, "usage-expanded");
-}
-
-export async function expectOnUsageScreen(page: Page): Promise {
- await expect(page).toHaveURL(/\/usage$/);
+/** The Usage modal's body: a dialog on wide layouts, a bottom sheet on compact ones. */
+export function usageModal(page: Page): Locator {
+ return visible(page, "usage-modal-body");
}
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
@@ -189,23 +185,53 @@ export async function togglePin(scope: Locator, source: string, window: string)
await expect(row).toBeChecked({ checked: !pinned });
}
-/** The footer's Usage icon, which is there whether or not the Usage item is on. */
-export async function openUsageScreenFromIcon(page: Page): Promise {
+/**
+ * The footer's Usage icon, which is there whether or not the Usage item is on. On a phone the
+ * sidebar drawer has to be open.
+ */
+export async function openUsageFromIcon(page: Page): Promise {
await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 });
- await expectOnUsageScreen(page);
+ await expect(usageModal(page)).toBeVisible();
}
-/** Both footer entry points open Usage over the current screen on a phone. */
-export async function openUsageSheetFromIcon(page: Page): Promise {
- await page.getByRole("button", { name: "Usage", exact: true }).click();
- await expect(usageSheet(page)).toBeVisible();
+/** The footer's Usage item, its summary of pinned windows. */
+export async function openUsageFromItem(page: Page): Promise {
+ await usageItem(page).click();
+ await expect(usageModal(page)).toBeVisible();
}
-export async function closeUsageSheet(page: Page): Promise {
- const sheet = usageSheet(page);
+/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */
+export async function scrollUsage(page: Page, deltaY: number): Promise {
+ const body = await usageModal(page).boundingBox();
+ const viewport = page.viewportSize();
+ if (!body || !viewport) throw new Error("Usage must be open before scrolling it.");
+ // The middle of the part of the body on screen; the body itself runs past the viewport.
+ const top = Math.max(body.y, 0);
+ const bottom = Math.min(body.y + body.height, viewport.height);
+ await page.mouse.move(body.x + body.width / 2, (top + bottom) / 2);
+ 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 {
+ 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 {
+ await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click();
+ await expect(usageModal(page)).toHaveCount(0);
+}
+
+/** A tap on the backdrop above the bottom sheet. */
+export async function closeUsageFromBackdrop(page: Page): Promise {
+ const sheet = usageModal(page);
await waitForSettledPosition(sheet);
- const bounds = await sheet.boundingBox();
- if (!bounds) throw new Error("Usage sheet must be visible before closing it.");
+ const bounds = await visible(page, "usage-modal").boundingBox();
+ if (!bounds) throw new Error("Usage must be open before closing it.");
await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2);
await expect(sheet).toHaveCount(0);
}
@@ -214,7 +240,7 @@ function summaryInSidebarSwitch(page: Page): Locator {
return page.getByRole("switch", { name: "Summary in sidebar", exact: true });
}
-/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */
+/** Turns the sidebar Usage summary on or off from the Usage modal's Settings. */
export async function setSummaryInSidebar(page: Page, on: boolean): Promise {
await openUsageOptions(page);
await summaryInSidebarSwitch(page).click();
@@ -275,9 +301,3 @@ export async function refreshAllUsage(page: Page): Promise {
export async function openCompactSidebar(page: Page): Promise {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
}
-
-/** On a phone the Usage screen has a back header instead of the sidebar menu. */
-export async function leaveUsageScreen(page: Page): Promise {
- await page.getByRole("button", { name: "Back", exact: true }).first().click();
- await expect(page).not.toHaveURL(/\/usage$/);
-}
diff --git a/packages/app/index.ts b/packages/app/index.ts
index 03b518fd2c..82d47bfe8c 100644
--- a/packages/app/index.ts
+++ b/packages/app/index.ts
@@ -1,10 +1,6 @@
-// Polyfill crypto.randomUUID for React Native before any other imports
-import { polyfillCrypto } from "./src/polyfills/crypto";
-polyfillCrypto();
-
-// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
-import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
-polyfillScreenOrientation();
+// Install runtime polyfills before any other module evaluates. Keep this the first import.
+// oxlint-disable-next-line import/no-unassigned-import -- Polyfills install on import.
+import "./src/polyfills/install-early";
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles";
diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt
index 7d6fcee8ed..c7007776ba 100644
--- a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt
+++ b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt
@@ -9,7 +9,11 @@ import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.BridgeReactContext
+import com.facebook.react.bridge.JavaOnlyArray
+import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping
+import com.facebook.react.uimanager.DisplayMetricsHolder
+import com.facebook.react.uimanager.ReactStylesDiffMap
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
@@ -47,16 +51,29 @@ class ScrollSelectionTest {
assertNotEquals(before, scroll.scrollY)
}
- private fun withScroll(inverted: Boolean, check: (ReactScrollView, TextView) -> Unit) {
+ @Test fun verticalScaleStillKeepsFocusAndSelectionInPlace() = withScroll(inverted = true, transform = "scaleY") { scroll, text ->
+ val before = scroll.scrollY
+ assertTrue(text.requestFocus())
+ text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
+ assertEquals(before, scroll.scrollY)
+ }
+
+ @Test fun horizontalScaleStillRevealsSelectedText() = withScroll(inverted = true, transform = "scaleX") { scroll, text ->
+ val before = scroll.scrollY
+ text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
+ assertNotEquals(before, scroll.scrollY)
+ }
+
+ private fun withScroll(inverted: Boolean, transform: String = "scale", check: (ReactScrollView, TextView) -> Unit) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.runOnMainSync {
val appContext = instrumentation.targetContext
+ DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = BridgeReactContext(appContext)
val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
- scroll.scaleY = if (inverted) -1f else 1f
val content = FrameLayout(context)
val text = TextView(context).apply {
setText("Selectable timeline text", TextView.BufferType.SPANNABLE)
@@ -68,6 +85,13 @@ class ScrollSelectionTest {
content.layout(0, 0, 300, 2000)
scroll.measure(exact(300), exact(300))
scroll.layout(0, 0, 300, 300)
+ // Apply RN's actual Android inversion through the manager's transform decomposition.
+ val transforms = JavaOnlyArray.of(JavaOnlyMap.of(transform, if (inverted) -1.0 else 1.0))
+ manager.updateProperties(scroll, ReactStylesDiffMap(JavaOnlyMap.of("transform", transforms)))
+ if (inverted && transform == "scale") {
+ assertEquals(1f, scroll.scaleY, 0f)
+ assertEquals(180f, kotlin.math.abs(scroll.rotation), 0f)
+ }
scroll.scrollTo(0, 400)
check(scroll, text)
}
diff --git a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt
index c3a2a4373d..71ff179cb1 100644
--- a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt
+++ b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt
@@ -1,5 +1,6 @@
package sh.paseo.scroll
+import android.graphics.Matrix
import android.graphics.Rect
import com.facebook.react.ReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage
@@ -27,9 +28,15 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
}
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
+ private val transformValues = FloatArray(9)
+
// Both RN focus handling and Android selection visibility requests use this calculation.
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
// movement to user gestures and explicit scroll commands.
- override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
- if (scaleY < 0) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
+ override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
+ // RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
+ matrix.getValues(transformValues)
+ if (transformValues[Matrix.MSCALE_Y] < 0) return 0
+ return super.computeScrollDeltaToGetChildRectOnScreen(rect)
+ }
}
diff --git a/packages/app/package.json b/packages/app/package.json
index 5ed29a2e22..670e0d93ab 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
- "version": "0.11.0-beta.4",
+ "version": "0.12.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -104,7 +104,11 @@
"i18next": "^26.3.0",
"lucide-react-native": "^1.50.0",
"markdown-it": "^10.0.0",
+ "mdast-util-from-markdown": "2.0.3",
+ "mdast-util-gfm-table": "^2.0.0",
"mermaid": "^11.16.0",
+ "micromark-extension-gfm-table": "^2.1.2",
+ "micromark-util-decode-string": "2.0.1",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
@@ -114,15 +118,14 @@
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
+ "react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.21.12",
- "react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
- "react-native-uitextview": "^2.2.0",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
@@ -164,6 +167,32 @@
"wrangler": "^4.105.0",
"ws": "^8.20.0"
},
+ "enriched-markdown": {
+ "enableMath": false,
+ "enableCodeHighlight": true,
+ "codeHighlightLanguages": [
+ "json",
+ "html",
+ "css",
+ "markdown",
+ "yaml",
+ "go",
+ "java",
+ "javascript",
+ "python",
+ "c",
+ "rust",
+ "bash",
+ "typescript",
+ "tsx",
+ "cpp",
+ "swift",
+ "php",
+ "ruby",
+ "c-sharp"
+ ],
+ "enableVideo": false
+ },
"reanimated": {
"staticFeatureFlags": {
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
diff --git a/packages/app/plugins/with-android-optional-camera.js b/packages/app/plugins/with-android-optional-camera.js
new file mode 100644
index 0000000000..dd5776e155
--- /dev/null
+++ b/packages/app/plugins/with-android-optional-camera.js
@@ -0,0 +1,24 @@
+const { withAndroidManifest } = require("expo/config-plugins");
+
+// Requesting CAMERA implies these features are required, which hides the app
+// on Google Play for devices without a camera. Pairing works without QR scanning.
+const OPTIONAL_CAMERA_FEATURES = ["android.hardware.camera", "android.hardware.camera.autofocus"];
+
+function withAndroidOptionalCamera(config) {
+ return withAndroidManifest(config, (modConfig) => {
+ const manifest = modConfig.modResults.manifest;
+ const features = manifest["uses-feature"] ?? [];
+ for (const name of OPTIONAL_CAMERA_FEATURES) {
+ const existing = features.find((feature) => feature.$["android:name"] === name);
+ if (existing) {
+ existing.$["android:required"] = "false";
+ } else {
+ features.push({ $: { "android:name": name, "android:required": "false" } });
+ }
+ }
+ manifest["uses-feature"] = features;
+ return modConfig;
+ });
+}
+
+module.exports = withAndroidOptionalCamera;
diff --git a/packages/app/public/index.html b/packages/app/public/index.html
index f7116fc86b..a95e7b8c9e 100644
--- a/packages/app/public/index.html
+++ b/packages/app/public/index.html
@@ -70,6 +70,15 @@
-webkit-app-region: no-drag !important;
}
+ /* Electron does not clip app-region rectangles to scroll viewports.
+ Content-only viewports emit no window regions, including from their
+ controls. Visible controls here use normal DOM hit testing.
+ Use initial: Blink parses the explicit value none as no-drag. */
+ [data-window-content],
+ [data-window-content] * {
+ -webkit-app-region: initial !important;
+ }
+
/* Suppress the browser's default focus outline — it appears on mouse
clicks and looks out of place against our themed surfaces. Keep a
themed ring for keyboard users via :focus-visible. */
diff --git a/packages/app/scripts/generate-markdown-image-placeholder.mjs b/packages/app/scripts/generate-markdown-image-placeholder.mjs
new file mode 100644
index 0000000000..4e147161e4
--- /dev/null
+++ b/packages/app/scripts/generate-markdown-image-placeholder.mjs
@@ -0,0 +1,27 @@
+// Regenerate from the repo root: node packages/app/scripts/generate-markdown-image-placeholder.mjs
+// sharp is already provided by the development dependency tree; it is not used at runtime.
+import { readFile, writeFile } from "node:fs/promises";
+import sharp from "sharp";
+
+// Lucide Image (ISC), lucide-react-native/dist/esm/icons/image.mjs.
+const svg = ``;
+const png = await sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer();
+const uri = `data:image/png;base64,${png.toString("base64")}`;
+const sourceFile = new URL("../src/components/markdown/source.ts", import.meta.url);
+const source = await readFile(sourceFile, "utf8");
+await writeFile(
+ sourceFile,
+ source.replace(
+ /export const PENDING_MARKDOWN_IMAGE =\s*"[^"]*";/,
+ `export const PENDING_MARKDOWN_IMAGE =\n "${uri}";`,
+ ),
+);
+console.log(`Generated 640×640 Markdown image placeholder: ${png.length} bytes`);
diff --git a/packages/app/src/agent-controls/labels.test.ts b/packages/app/src/agent-controls/labels.test.ts
index 4f1302fd1b..7310464896 100644
--- a/packages/app/src/agent-controls/labels.test.ts
+++ b/packages/app/src/agent-controls/labels.test.ts
@@ -29,4 +29,15 @@ describe("formatThinkingOptionLabel", () => {
expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard");
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high");
});
+
+ it("preserves provider-supplied xhigh labels with extra detail", () => {
+ expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (56 | 8m)" })).toBe(
+ "X high (56 | 8m)",
+ );
+ expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (45 | 6m)" })).toBe(
+ "X high (45 | 6m)",
+ );
+ expect(formatThinkingOptionLabel({ id: "xhigh" })).toBe("Extra high");
+ expect(formatThinkingOptionLabel({ id: "xhigh", label: "Extra high" })).toBe("Extra high");
+ });
});
diff --git a/packages/app/src/agent-controls/labels.ts b/packages/app/src/agent-controls/labels.ts
index ff32aaf4dc..704298f78c 100644
--- a/packages/app/src/agent-controls/labels.ts
+++ b/packages/app/src/agent-controls/labels.ts
@@ -34,10 +34,9 @@ export function formatAgentModeLabel(mode: ControlLabelInput): string {
export function formatThinkingOptionLabel(option: ControlLabelInput): string {
const rawLabel = (option.label ?? option.id).trim();
- const compactId = option.id.replace(/[\s_-]+/g, "").toLowerCase();
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
- if (compactId === "xhigh" || compactLabel === "xhigh") {
+ if (compactLabel === "xhigh" || compactLabel === "extrahigh") {
return i18n.t("agentControls.thinking.extraHigh");
}
diff --git a/packages/app/src/agent-stream/chat-find/ranges.browser.test.ts b/packages/app/src/agent-stream/chat-find/ranges.browser.test.ts
index c1d84c1b42..631b8f1d27 100644
--- a/packages/app/src/agent-stream/chat-find/ranges.browser.test.ts
+++ b/packages/app/src/agent-stream/chat-find/ranges.browser.test.ts
@@ -65,3 +65,14 @@ it("matches whitespace across text nodes without inspecting hidden link destinat
"world\nline",
]);
});
+
+it("keeps semantic Markdown paragraphs separate while finding inline matches", () => {
+ const row = content(
+ "İ😀hello world; hello world.
another paragraph
",
+ );
+ expect(findRenderedMatches(row, "hello world").map((range) => range.toString())).toEqual([
+ "hello world",
+ "hello world",
+ ]);
+ expect(findRenderedMatches(row, ".another")).toEqual([]);
+});
diff --git a/packages/app/src/agent-stream/chat-find/ranges.web.ts b/packages/app/src/agent-stream/chat-find/ranges.web.ts
index 91ed7bff6a..ea2e901c2d 100644
--- a/packages/app/src/agent-stream/chat-find/ranges.web.ts
+++ b/packages/app/src/agent-stream/chat-find/ranges.web.ts
@@ -1,10 +1,10 @@
const BLOCKS = ["p", "pre", "li", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6"]
- .map((tag) => `[data-paseo-markdown-tag="${tag}"]`)
+ .flatMap((tag) => [tag, `[data-paseo-markdown-tag="${tag}"]`])
.join(",");
const IGNORED =
'[data-paseo-markdown-ignore="true"], [aria-hidden="true"], button, [role="button"], svg, script, style';
-/** The rows of one message, in reading order. Each is one Markdown block. */
+/** Rendered rows carrying a source message identity, in reading order. */
export function findMessageRows(root: HTMLElement | null, messageId: string): HTMLElement[] {
if (!root) return [];
return Array.from(
diff --git a/packages/app/src/agent-stream/chat-find/types.ts b/packages/app/src/agent-stream/chat-find/types.ts
index cce58be96e..4fdf543a5e 100644
--- a/packages/app/src/agent-stream/chat-find/types.ts
+++ b/packages/app/src/agent-stream/chat-find/types.ts
@@ -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;
revealLoadedMessage(messageId: string): boolean;
- visibleMessageIds: ReadonlySet;
+ visibleMessageIds: MountedMessageIds;
children: ReactNode;
}
export interface ChatFindExpansionProps {
diff --git a/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts b/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts
index 2bd1e291b4..a7cf29994c 100644
--- a/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts
+++ b/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts
@@ -98,3 +98,23 @@ function highlightedRow(): string | undefined {
);
return row?.dataset.historyRowId;
}
+
+it("waits for an asynchronous renderer before counting its matches", async () => {
+ const root = transcript("message-1", ["needle"]);
+ const content = root.querySelector('[data-message-text="true"]')!;
+ content.setAttribute("aria-busy", "true");
+ let settled = false;
+ const reveal = viewport(root, {})
+ .reveal("message-1", "needle", 0, new AbortController().signal)
+ .then((result) => {
+ settled = true;
+ return result;
+ });
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
+ );
+ expect(settled).toBe(false);
+ content.textContent = "needle and another needle";
+ content.removeAttribute("aria-busy");
+ expect(await reveal).toEqual({ occurrence: 0, count: 2 });
+});
diff --git a/packages/app/src/agent-stream/chat-find/viewport.web.ts b/packages/app/src/agent-stream/chat-find/viewport.web.ts
index 6bbb3a704b..4fef1f6abc 100644
--- a/packages/app/src/agent-stream/chat-find/viewport.web.ts
+++ b/packages/app/src/agent-stream/chat-find/viewport.web.ts
@@ -21,8 +21,7 @@ export function createFindViewport({
clear() {
CSS.highlights.delete(highlightName);
},
- // A message renders as one row per Markdown block, so an occurrence can sit in
- // any of them and the chosen one decides where the viewport lands.
+ // The source message owns navigation; the rendered occurrence owns its position.
reveal(messageId, query, occurrence, signal) {
return new Promise((resolve, reject) => {
let frame = 0;
@@ -61,6 +60,10 @@ export function createFindViewport({
frame = requestAnimationFrame(poll);
return;
}
+ if (rows.some((row) => row.querySelector('[aria-busy="true"]'))) {
+ frame = requestAnimationFrame(poll);
+ return;
+ }
const ranges = findMessageMatches(getRoot(), messageId, query);
const index =
occurrence < 0 ? ranges.length - 1 : Math.min(occurrence, ranges.length - 1);
diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx
index 4b2139fef6..bd5a73ce94 100644
--- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx
+++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx
@@ -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() } },
+ { initialProps: { mountedHistoryMessageIds: new Set() } },
);
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(),
+ mountedHistoryMessageIds: new Set(),
},
},
);
@@ -352,11 +352,11 @@ describe("useChatOutline", () => {
act(() => result.current.jumpToPrompt(1));
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
- rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set() });
+ rerender({ tail: [fetchedPrompt], mountedHistoryMessageIds: new Set() });
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(),
revealLoadedMessage,
}),
);
diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts
index 015b883d21..cfdb26b2a5 100644
--- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts
+++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts
@@ -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;
onJumpError: () => void;
- visibleMessageIds?: ReadonlySet;
+ // Message ids of the history the viewport has mounted. Live head messages are always mounted.
+ mountedHistoryMessageIds?: ReadonlySet;
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(null);
@@ -62,18 +81,19 @@ export function useChatOutline({
const readingSeqRef = useRef(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 };
}
diff --git a/packages/app/src/agent-stream/footer-spacing.test.ts b/packages/app/src/agent-stream/footer-spacing.test.ts
index a2414fff5c..5fa3535bce 100644
--- a/packages/app/src/agent-stream/footer-spacing.test.ts
+++ b/packages/app/src/agent-stream/footer-spacing.test.ts
@@ -49,17 +49,6 @@ function assistantMessage(id: string): Extract {
- return {
- ...assistantMessage(id),
- blockGroupId: "response",
- blockIndex,
- };
-}
-
function timingFor(assistantId: string): Map {
return new Map([
[
@@ -109,30 +98,4 @@ describe("turn footer spacing", () => {
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
},
);
-
- it.each([
- ["web", false],
- ["web", true],
- ["native", false],
- ["native", true],
- ] as const)(
- "compacts both edges of the final split paragraph on %s when active=%s",
- (platform, isTurnActive) => {
- const strategy = strategyFor(platform);
- const first = assistantBlock("first", 0);
- const last = assistantBlock("last", 1);
- const layout = layoutStream({
- strategy,
- isTurnActive,
- history: strategy.orderTail([userMessage("user"), first]),
- liveHead: strategy.orderHead([last]),
- timingByAssistantId: timingFor(last.id),
- });
- const firstLayout = layout.history.find((item) => item.item.id === first.id);
- const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
-
- expect(firstLayout?.assistantSpacing).toBe("compactBottom");
- expect(lastLayout?.assistantSpacing).toBe("compactBoth");
- },
- );
});
diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts
index 3ab2a9f8f0..f10fde2cb1 100644
--- a/packages/app/src/agent-stream/layout.test.ts
+++ b/packages/app/src/agent-stream/layout.test.ts
@@ -25,7 +25,7 @@ function userMessage(id: string, seed: number): Extract {
return {
@@ -33,7 +33,6 @@ function assistantMessage(
id,
text: id,
timestamp: timestamp(seed),
- ...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
...(turnId ? { turnId } : {}),
};
}
@@ -318,7 +317,7 @@ describe("layoutStream", () => {
expect(assistantRow.frameOrder).toBe("content-then-footer");
});
- it("compacts assistant block spacing across the history and live-head boundary", () => {
+ it("keeps separate messages spaced across history and the live head", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
const layout = layoutFor({
@@ -328,8 +327,8 @@ describe("layoutStream", () => {
timingIds: [historyBlock.id, headBlock.id],
});
- expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
- expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBoth");
+ expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("default");
+ expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBottom");
});
it.each(["web", "android"] as const)(
diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts
index d460c1fa2e..82ef98db54 100644
--- a/packages/app/src/agent-stream/layout.ts
+++ b/packages/app/src/agent-stream/layout.ts
@@ -1,6 +1,6 @@
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
-import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
+import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
import { continuesResponse, continuesTurn, isResponseBoundary } from "./turn-membership";
@@ -311,10 +311,8 @@ function layoutSegmentItem(
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
- const assistantSpacing = getAssistantBlockSpacing({
+ const assistantSpacing = getAssistantMessageSpacing({
item,
- aboveItem,
- belowItem,
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
});
diff --git a/packages/app/src/agent-stream/model-retention.test.ts b/packages/app/src/agent-stream/model-retention.test.ts
new file mode 100644
index 0000000000..c82b29d301
--- /dev/null
+++ b/packages/app/src/agent-stream/model-retention.test.ts
@@ -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[] {
+ const windows: WeakRef[] = [];
+ 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);
+});
diff --git a/packages/app/src/agent-stream/model.test.ts b/packages/app/src/agent-stream/model.test.ts
index e29792ab02..dec4c489b9 100644
--- a/packages/app/src/agent-stream/model.test.ts
+++ b/packages/app/src/agent-stream/model.test.ts
@@ -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,
+ });
+ },
+ );
});
diff --git a/packages/app/src/agent-stream/model.ts b/packages/app/src/agent-stream/model.ts
index 89eb73a7be..92f8be7141 100644
--- a/packages/app/src/agent-stream/model.ts
+++ b/packages/app/src/agent-stream/model.ts
@@ -53,11 +53,29 @@ const splitHistoryCache = new WeakMap<
StreamItem[],
Map>
>();
-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>
+ { 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>;
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,
diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts
index 54160ebcb9..dcb52fd24b 100644
--- a/packages/app/src/agent-stream/presentation.test.ts
+++ b/packages/app/src/agent-stream/presentation.test.ts
@@ -1,7 +1,5 @@
-import { QueryClient } from "@tanstack/react-query";
-import { describe, expect, it } from "vitest";
-import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate";
+import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
import type { InstalledPlugin } from "@/plugins/types";
import {
applyStreamEvent,
@@ -10,9 +8,11 @@ import {
type ToolCallItem,
type UserMessageItem,
} from "@/types/stream";
-import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
-import { createStreamPresentation } from "./presentation";
+import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
+import { QueryClient } from "@tanstack/react-query";
+import { describe, expect, it } from "vitest";
import { buildAgentStreamRenderModel } from "./model";
+import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
const runtime = {
paseo: {},
@@ -29,7 +29,7 @@ const runtime = {
} as unknown as PluginClientRuntime;
function installProbe(
- itemType: "assistant_message" | "tool_call" | "reasoning",
+ itemType: "user_message" | "assistant_message" | "tool_call" | "reasoning",
condition = "true",
): InstalledPlugin {
const clientBundle = `(function() {
@@ -123,6 +123,25 @@ function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[]
return [...result.tail, ...result.head];
}
+describe("whole-message Markdown presentation", () => {
+ it("keeps paragraphs, fences and quotes in one progressively updated row", () => {
+ const present = createStreamPresentation();
+ const text = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
+ for (const length of [10, 30, text.length]) {
+ const source = hydrateStreamState([
+ { event: assistant(text.slice(0, length)), timestamp: new Date(1000) },
+ ]);
+ const result = present({
+ ...presentationOptions,
+ tail: [],
+ head: source,
+ transform: undefined,
+ });
+ expect(rows(result)).toEqual(source);
+ }
+ });
+});
+
describe("stream presentation through installed plugins", () => {
it("offers every source tool call to an installed transformer in Overview mode", () => {
const calls = [toolCall("call-1", "bash"), toolCall("call-2", "read")];
@@ -160,6 +179,23 @@ describe("stream presentation through installed plugins", () => {
},
);
+ it("addresses a prompt a plugin replaced by the prompt's own id", () => {
+ const harness = streamHarness(installedTransform(installProbe("user_message")));
+ const rendered = rows(
+ harness.send({
+ type: "timeline",
+ provider: "claude",
+ item: { type: "user_message", messageId: "prompt-1", text: "Earlier prompt" },
+ }),
+ );
+ const source = rows(harness.source());
+
+ expect(source).toMatchObject([{ kind: "user_message", id: "prompt-1" }]);
+ expect(rendered).toMatchObject([{ kind: "plugin", id: "probe-plugin/prompt-1/0" }]);
+ // The chat outline jumps to the source id; the row it lands on is the plugin's.
+ expect(rendered.map(getStreamItemMessageId)).toEqual(["prompt-1"]);
+ });
+
it("finishes one message before streaming a different message ID", () => {
const harness = streamHarness(installedTransform(installProbe("assistant_message")));
harness.send(assistant("First\n\nBody", "first"));
@@ -181,26 +217,17 @@ describe("stream presentation through installed plugins", () => {
{ text: "Header\n\nBody", phase: "streaming" },
]);
expect(rows(harness.render(transform))).toHaveLength(1);
- expect(rows(harness.render()).map((item) => item.kind)).toEqual([
- "assistant_message",
- "assistant_message",
- ]);
+ expect(rows(harness.render()).map((item) => item.kind)).toEqual(["assistant_message"]);
});
- it("keeps completed native blocks stable while the last block grows and finishes", () => {
+ it("retains the complete message when streaming finishes", () => {
const harness = streamHarness();
- const first = harness.send(assistant("Intro\n\n"));
- const growing = harness.send(assistant("\n\nAfter\n"));
- const last = harness.send(assistant("- item"));
+ harness.send(assistant("Intro\n\n"));
+ const last = harness.send(assistant("\n\nAfter\n- item"));
const completed = harness.send({ type: "turn_completed", provider: "claude" });
- expect(first.tail).toMatchObject([{ text: "Intro", blockIndex: 0 }]);
- expect(first.head).toMatchObject([{ text: "", blockIndex: 1 }]);
- expect(growing.tail[0]).toBe(first.tail[0]);
- expect(growing.tail[1]).toBe(first.head[0]);
- expect(last.tail).toBe(growing.tail);
- expect(last.head).toMatchObject([{ text: "After\n- item", blockIndex: 2 }]);
- expect(completed.tail).toEqual(rows(last));
- expect(completed.tail[2]).toBe(last.head[0]);
+ expect(last.tail).toEqual([]);
+ expect(last.head).toMatchObject([{ text: "Intro\n\n\n\nAfter\n- item" }]);
+ expect(completed.tail).toEqual(last.head);
expect(completed.head).toEqual([]);
});
@@ -208,8 +235,8 @@ describe("stream presentation through installed plugins", () => {
const harness = streamHarness();
harness.send(assistant("Intro\n\n```ts\nconst a = 1;"));
const result = harness.send(assistant("\n\nconst b = 2;"));
- expect(result.tail).toMatchObject([{ text: "Intro" }]);
- expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
+ expect(result.tail).toEqual([]);
+ expect(result.head).toMatchObject([{ text: "Intro\n\n```ts\nconst a = 1;\n\nconst b = 2;" }]);
});
// Codex streams a line break and the next line's indent as their own chunks.
@@ -227,14 +254,13 @@ describe("stream presentation through installed plugins", () => {
// which is what lets find, scroll-to-message and history reveal address it by id.
// A link reference definition stays with the paragraph that uses it, so the split
// never leaves an empty row behind and the reference still resolves.
- it("splits an assistant message into the same blocks through history and through the live head", () => {
+ it("preserves source identity through history and the live head", () => {
const source = hydrateStreamState([
{
event: assistant("[Link][docs]\n\n[docs]: https://example.com\n\nClosing paragraph."),
timestamp: new Date(1000),
},
]);
- const messageId = source[0]!.id;
const present = (placement: "tail" | "head") =>
rows(
createStreamPresentation()({
@@ -244,20 +270,7 @@ describe("stream presentation through installed plugins", () => {
transform: undefined,
}),
);
- expect(present("tail")).toMatchObject([
- {
- id: `${messageId}:block:0`,
- blockGroupId: messageId,
- blockIndex: 0,
- text: "[Link][docs]\n\n[docs]: https://example.com",
- },
- {
- id: `${messageId}:block:1`,
- blockGroupId: messageId,
- blockIndex: 1,
- text: "Closing paragraph.",
- },
- ]);
+ expect(present("tail")).toEqual(source);
const blocks = (items: StreamItem[]) =>
items.map((item) => [item.id, item.kind === "assistant_message" ? item.text : null]);
expect(blocks(present("head"))).toEqual(blocks(present("tail")));
@@ -265,7 +278,7 @@ describe("stream presentation through installed plugins", () => {
// Rows are addressed by id from outside presentation, so the same text has to land on
// the same ids whether the reader watched it arrive or opened the chat afterwards.
- it("gives a streamed message the block ids its hydrated text would have had", () => {
+ it("gives a streamed message the same id as its hydrated text", () => {
const text = "Intro paragraph.\n\n# Heading\n\n- first\n- second\n\nClosing paragraph.";
const harness = streamHarness();
for (let end = 8; end < text.length; end += 8) {
@@ -287,7 +300,7 @@ describe("stream presentation through installed plugins", () => {
);
});
- it("repoints a block at the canonical source that replaced the streamed one", () => {
+ it("uses the canonical source that replaced the streamed message", () => {
const harness = streamHarness();
harness.send(assistant("Streamed answer"));
const streamed = rows(harness.render());
@@ -321,15 +334,8 @@ describe("stream presentation through installed plugins", () => {
head: [],
transform: undefined,
});
- expect(result.tail).toMatchObject([
- {
- id: `${messageId}:block:0`,
- blockGroupId: messageId,
- blockIndex: 0,
- text: "Only one paragraph.",
- },
- ]);
- expect(result.tail[0]!.id).not.toBe(messageId);
+ expect(result.tail).toEqual(source);
+ expect(result.tail[0]!.id).toBe(messageId);
});
it("continues to stream inline reasoning with a stable plugin row", () => {
@@ -546,13 +552,7 @@ describe("timeline presentation", () => {
text: "Example Explain this XML. ",
},
];
- expect(projectTimelineItems(items)).toEqual(
- items.map((item) =>
- item.kind === "assistant_message"
- ? { ...item, id: `${item.id}:block:0`, blockGroupId: item.id, blockIndex: 0 }
- : item,
- ),
- );
+ expect(projectTimelineItems(items)).toEqual(items);
expect(projectTimelineItems(items)[0]).toBe(items[0]);
});
diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts
index 8300996348..4459486a7b 100644
--- a/packages/app/src/agent-stream/presentation.ts
+++ b/packages/app/src/agent-stream/presentation.ts
@@ -1,13 +1,12 @@
-import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream";
import type { TimelineItemTransform } from "@/plugins/timeline/model";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
-import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import {
prepareToolCallHistory,
projectToolCallDetailLevel,
type PreparedToolCallHistory,
type ToolCallDetailLevel,
} from "@/tool-calls/detail-level/projection";
+import type { StreamItem, UserMessageItem } from "@/types/stream";
interface PresentationInput {
tail: StreamItem[];
@@ -17,38 +16,13 @@ interface PresentationInput {
isTurnActive: boolean;
}
-function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
- return previous.length === next.length && previous.every((item, index) => item === next[index])
- ? previous
- : next;
-}
-
-/**
- * The source message a display row belongs to. Every assistant message is split into
- * Markdown blocks, so an assistant row id never equals its message id; anything that
- * addresses a message — find, scroll-to-message, history reveal — asks for this.
- */
+/** Find and scrolling address a plugin row by the source it replaces. */
export function getStreamItemMessageId(item: StreamItem): string {
- return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id;
+ if (item.kind === "plugin") return item.sourceId ?? item.id;
+ return item.id;
}
-/**
- * A block is reusable only when it still stands for the same source state. Text and
- * turn are what the reader sees; cursor and timestamp are what the timeline reads back
- * through the row — a reused block carrying a pre-canonical cursor reports a stale
- * reading position and weakens fork-boundary resolution.
- */
-function isReusableBlock(block: AssistantMessageItem, source: AssistantMessageItem, id: string) {
- return (
- block.id === id &&
- block.turnId === source.turnId &&
- block.timestamp.getTime() === source.timestamp.getTime() &&
- block.timelineCursor?.epoch === source.timelineCursor?.epoch &&
- block.timelineCursor?.seq === source.timelineCursor?.seq
- );
-}
-
-/** Source messages reach plugins before any Markdown splitting or Overview grouping. */
+/** Source messages reach plugins before display projection. */
export function createStreamPresentation() {
const userMessageCache = new WeakMap();
@@ -67,115 +41,39 @@ export function createStreamPresentation() {
return projected;
}
- const blocksBySource = new WeakMap();
- let liveSources = new Map();
let historySource: StreamItem[] | undefined;
let historyTransform: TimelineItemTransform | undefined;
let historyRows: StreamItem[] = [];
- let displayTail: StreamItem[] = [];
- let displayHistory: StreamItem[] | undefined;
- let promotedRows: StreamItem[] = [];
let preparedTail: StreamItem[] | undefined;
let preparedLevel: ToolCallDetailLevel | undefined;
let preparedHistory: PreparedToolCallHistory | null = null;
- /**
- * One display row per Markdown block. Each block renders on its own, so a construct
- * that needs context from another block does not resolve: `splitMarkdownBlocks` keeps
- * link reference definitions with the block that uses them, but a reference pointing
- * at a definition several blocks away renders as literal text. Streamed messages
- * always behaved this way; history now matches them.
- */
- function nativeBlocks(item: StreamItem): StreamItem[] {
- if (item.kind === "user_message") return [presentUserMessage(item)];
- if (item.kind !== "assistant_message") return [item];
- const cached = blocksBySource.get(item);
- if (cached) return cached;
- const previousSource = liveSources.get(item.id);
- const previous = previousSource && blocksBySource.get(previousSource);
- // Parse only the growing last block on append, as the old live reducer did.
- // Canonical text replacements are parsed afresh instead of joining fragments.
- const isAppend = previousSource && previous && item.text.startsWith(previousSource.text);
- let prefix: AssistantMessageItem[] = [];
- let growingText = item.text;
- if (isAppend) {
- prefix = previous.slice(0, -1);
- growingText =
- previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length);
- }
- const parsed = splitMarkdownBlocks(growingText);
- // Whitespace-only text has no block, and a message still owns exactly one row.
- const textBlocks = parsed.length > 0 || prefix.length > 0 ? parsed : [""];
-
- const blocks = [...prefix];
- for (const [offset, text] of textBlocks.entries()) {
- const index = prefix.length + offset;
- let blockText = text;
- if (offset === textBlocks.length - 1) {
- // The split drops trailing blank lines, including a line break followed only
- // by the next line's indent. Keep them so the next append continues that line.
- const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
- blockText += trailingBlankLines;
- }
- const existing = previous?.[index];
- const id = `${item.id}:block:${index}`;
- if (existing?.text === blockText && isReusableBlock(existing, item, id)) {
- blocks.push(existing);
- continue;
- }
- blocks.push({
- ...item,
- id,
- blockGroupId: item.id,
- blockIndex: index,
- text: blockText,
- });
- }
- blocksBySource.set(item, blocks);
- return blocks;
+ function presentRows(item: StreamItem): StreamItem[] {
+ return [item.kind === "user_message" ? presentUserMessage(item) : item];
}
return (input: PresentationInput) => {
// Retained history is not reprojected or regrouped on each live text update.
if (historySource !== input.tail || historyTransform !== input.transform) {
- // One rendering path: history splits the same way the live head does, and
- // `blocksBySource` keeps both the live block identities and the split cost
- // from being paid again when only the tail's array identity changed.
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap(
- nativeBlocks,
+ (item) => presentRows(item),
);
historySource = input.tail;
historyTransform = input.transform;
}
const head: StreamItem[] = [];
- const promoted: StreamItem[] = [];
- const nextLiveSources = new Map();
for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) {
- const blocks = nativeBlocks(item);
- if (item.kind === "assistant_message") nextLiveSources.set(item.id, item);
- if (item.kind === "assistant_message" && blocks.length > 1) {
- promoted.push(...blocks.slice(0, -1));
- head.push(blocks[blocks.length - 1]!);
- } else {
- head.push(...blocks);
- }
+ const blocks = presentRows(item);
+ head.push(...blocks);
}
- liveSources = nextLiveSources;
- const nextPromoted = retainItems(promotedRows, promoted);
- if (displayHistory !== historyRows || nextPromoted !== promotedRows) {
- displayTail = historyRows;
- if (nextPromoted.length > 0) displayTail = [...historyRows, ...nextPromoted];
- displayHistory = historyRows;
- promotedRows = nextPromoted;
- }
- if (preparedTail !== displayTail || preparedLevel !== input.level) {
- preparedHistory = prepareToolCallHistory(input.level, displayTail);
- preparedTail = displayTail;
+ if (preparedTail !== historyRows || preparedLevel !== input.level) {
+ preparedHistory = prepareToolCallHistory(input.level, historyRows);
+ preparedTail = historyRows;
preparedLevel = input.level;
}
return projectToolCallDetailLevel({
level: input.level,
- tail: displayTail,
+ tail: historyRows,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
diff --git a/packages/app/src/agent-stream/spacing.test.ts b/packages/app/src/agent-stream/spacing.test.ts
index dcf8a36844..07e9f185ff 100644
--- a/packages/app/src/agent-stream/spacing.test.ts
+++ b/packages/app/src/agent-stream/spacing.test.ts
@@ -1,135 +1,20 @@
import { describe, expect, it } from "vitest";
+import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
import type { StreamItem } from "@/types/stream";
-import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./spacing";
-
-function assistantBlock(params: {
- id: string;
- blockGroupId: string;
- blockIndex: number;
- text?: string;
-}): Extract {
- return {
- kind: "assistant_message",
- id: params.id,
- blockGroupId: params.blockGroupId,
- blockIndex: params.blockIndex,
- text: params.text ?? "",
- timestamp: new Date("2026-05-01T00:00:00.000Z"),
- };
-}
-
-function toolCallBlock(id: string): Extract {
- return {
- kind: "tool_call",
- id,
- timestamp: new Date("2026-05-01T00:00:00.000Z"),
- payload: {
- source: "orchestrator",
- data: {
- toolCallId: id,
- toolName: "bash",
- arguments: "cmd",
- result: null,
- status: "executing",
- },
- },
- };
-}
-
-describe("isSameAssistantBlockGroup", () => {
- it("returns true for two assistant blocks with the same blockGroupId", () => {
- const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
- const b = assistantBlock({ id: "b", blockGroupId: "group-1", blockIndex: 1 });
- expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(true);
- });
-
- it("returns false for blocks from different groups", () => {
- const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
- const b = assistantBlock({ id: "b", blockGroupId: "group-2", blockIndex: 0 });
- expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(false);
- });
-
- it("returns false when one item is not an assistant_message", () => {
- const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
- const tc = toolCallBlock("tc-1");
- expect(isSameAssistantBlockGroup({ item: a, other: tc })).toBe(false);
- });
-
- it("returns false for null neighbors", () => {
- const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
- expect(isSameAssistantBlockGroup({ item: a, other: null })).toBe(false);
- });
-});
-
-describe("getAssistantBlockSpacing", () => {
- it("returns default for non-assistant items", () => {
- const tc = toolCallBlock("tc-1");
- expect(getAssistantBlockSpacing({ item: tc, aboveItem: null, belowItem: null })).toBe(
- "default",
- );
- });
-
- it("returns default when no same-group neighbors exist", () => {
- const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
- expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
- });
-
- it("compacts the bottom edge when the turn footer follows the assistant block", () => {
- const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
- expect(
- getAssistantBlockSpacing({
- item: a,
- aboveItem: null,
- belowItem: null,
- hasFooterBelow: true,
- }),
- ).toBe("compactBottom");
- });
-
- it("returns compactTop when the item above is in the same block group", () => {
- const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
- const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
- expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: null })).toBe(
- "compactTop",
- );
- });
-
- it("returns compactBottom when the item below is in the same block group", () => {
- const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 0 });
- const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 1 });
- expect(getAssistantBlockSpacing({ item, aboveItem: null, belowItem: below })).toBe(
+const message: StreamItem = {
+ kind: "assistant_message",
+ id: "m",
+ text: "First\n\nSecond",
+ timestamp: new Date(0),
+};
+describe("whole-message spacing", () => {
+ it("compacts only the gap to its turn footer", () => {
+ expect(getAssistantMessageSpacing({ item: message })).toBe("default");
+ expect(getAssistantMessageSpacing({ item: message, hasFooterBelow: true })).toBe(
"compactBottom",
);
});
-
- it("returns compactBoth when both neighbors are in the same block group", () => {
- const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
- const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
- const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 2 });
- expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: below })).toBe(
- "compactBoth",
- );
- });
-
- it("spans the history/live-head boundary: tail gets compactBottom, head gets compactTop", () => {
- const tailBlock = assistantBlock({
- id: "group-1:block:0",
- blockGroupId: "group-1",
- blockIndex: 0,
- text: "First paragraph",
- });
- const headBlock = assistantBlock({
- id: "group-1:head",
- blockGroupId: "group-1",
- blockIndex: 1,
- text: "Second paragraph",
- });
-
- expect(
- getAssistantBlockSpacing({ item: tailBlock, aboveItem: null, belowItem: headBlock }),
- ).toBe("compactBottom");
- expect(
- getAssistantBlockSpacing({ item: headBlock, aboveItem: tailBlock, belowItem: null }),
- ).toBe("compactTop");
+ it("keeps separate messages separated", () => {
+ expect(getGapBetweenStreamItems(message, { ...message, id: "next" })).toBeGreaterThan(0);
});
});
diff --git a/packages/app/src/agent-stream/spacing.ts b/packages/app/src/agent-stream/spacing.ts
index 87bdf75f88..55761f71f3 100644
--- a/packages/app/src/agent-stream/spacing.ts
+++ b/packages/app/src/agent-stream/spacing.ts
@@ -1,35 +1,13 @@
import type { StreamItem } from "@/types/stream";
import { SPACING } from "@/styles/theme";
-export function isSameAssistantBlockGroup(params: {
- item: StreamItem | null | undefined;
- other: StreamItem | null | undefined;
-}): boolean {
- return (
- params.item?.kind === "assistant_message" &&
- params.other?.kind === "assistant_message" &&
- params.item.blockGroupId !== undefined &&
- params.item.blockGroupId === params.other.blockGroupId
- );
-}
-
-export function getAssistantBlockSpacing(params: {
+export function getAssistantMessageSpacing(params: {
item: StreamItem;
- aboveItem: StreamItem | null | undefined;
- belowItem: StreamItem | null | undefined;
hasFooterBelow?: boolean;
-}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
- if (params.item.kind !== "assistant_message") {
- return "default";
- }
- const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
- const compactBottom =
- params.hasFooterBelow ||
- isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
- if (compactTop && compactBottom) return "compactBoth";
- if (compactTop) return "compactTop";
- if (compactBottom) return "compactBottom";
- return "default";
+}): "default" | "compactBottom" {
+ return params.item.kind === "assistant_message" && params.hasFooterBelow
+ ? "compactBottom"
+ : "default";
}
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
@@ -62,8 +40,5 @@ export function getGapBetweenStreamItems(
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return SPACING[1];
}
- if (isSameAssistantBlockGroup({ item, other: belowItem })) {
- return SPACING[3];
- }
return SPACING[4];
}
diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx
index 47f2766a30..66409f23e1 100644
--- a/packages/app/src/agent-stream/strategy-native.tsx
+++ b/packages/app/src/agent-stream/strategy-native.tsx
@@ -42,6 +42,7 @@ import {
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
+import { useStreamTopOverlayInset } from "./top-overlay";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
@@ -274,12 +275,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
? undefined
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
+ const topOverlayInset = useStreamTopOverlayInset();
+ // The list is inverted, so its content's bottom padding is the visual top.
const listContentContainerStyle = useMemo(
() => [
baseListContentContainerStyle,
- { paddingBottom: streamKeyboardInset.contentContainerPaddingBottom },
+ {
+ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset,
+ },
+ ],
+ [
+ baseListContentContainerStyle,
+ streamKeyboardInset.contentContainerPaddingBottom,
+ topOverlayInset,
],
- [baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
);
const listInsetProps = useMemo(
() =>
@@ -582,8 +591,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
- initialNumToRender={12}
- windowSize={10}
+ // A history row can contain an entire long reply. Keep synchronous native
+ // Markdown measurement bounded while FlatList fills the surrounding window.
+ initialNumToRender={2}
+ maxToRenderPerBatch={2}
+ windowSize={5}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx
index 9078b0f1c3..1f61d0a14c 100644
--- a/packages/app/src/agent-stream/strategy-web.test.tsx
+++ b/packages/app/src/agent-stream/strategy-web.test.tsx
@@ -32,7 +32,7 @@ vi.mock("react-native-unistyles", () => ({
withUnistyles: (Component: React.ComponentType) => Component,
}));
-function userMessage(index: number): StreamItem {
+function userMessage(index: number): Extract {
return {
kind: "user_message",
id: `message-${index}`,
@@ -55,6 +55,30 @@ function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
};
}
+function MeasuredTestRow({ item }: { item: StreamItem }) {
+ const [loaded, setLoaded] = React.useState(false);
+ const load = React.useCallback(() => setLoaded(true), []);
+ const height = item.kind === "user_message" && item.text === "grown" ? 120 : 24;
+ const measure = React.useCallback(
+ (node: HTMLDivElement | null) => {
+ if (!node?.parentElement) return;
+ Object.defineProperty(node.parentElement, "offsetHeight", {
+ configurable: true,
+ value: height,
+ });
+ },
+ [height],
+ );
+ return (
+
+
+ {item.id}
+
+ );
+}
+
describe("createWebStreamStrategy", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
@@ -155,6 +179,75 @@ describe("createWebStreamStrategy", () => {
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
});
+ it("retains a row's DOM and local state when recent history becomes virtualized", () => {
+ const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ const item = userMessage(0);
+ const renderRow = (rowItem: StreamItem) => ;
+ const input: StreamRenderInput = {
+ agentId: "agent",
+ segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] },
+ boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false },
+ renderers: {
+ renderHistoryVirtualizedRow: renderRow,
+ renderHistoryMountedRow: renderRow,
+ renderLiveHeadRow: renderRow,
+ renderLiveAuxiliary: () => null,
+ },
+ listEmptyComponent: null,
+ viewportRef: React.createRef(),
+ routeBottomAnchorRequest: null,
+ isAuthoritativeHistoryReady: true,
+ onNearBottomChange: () => {},
+ onNearHistoryStart: () => true,
+ isLoadingOlderHistory: false,
+ hasOlderHistory: false,
+ olderHistoryProgressKey: null,
+ scrollEnabled: true,
+ listStyle: null,
+ baseListContentContainerStyle: null,
+ forwardListContentContainerStyle: null,
+ contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
+ };
+ act(() => root?.render(strategy.render(input)));
+ const row = container.querySelector('[data-history-row-id="message-0"]')!;
+ act(() => row.querySelector("button")!.click());
+ expect(row.querySelector("button")!.textContent).toBe("Loaded");
+ const viewport = container.querySelector('[data-testid="agent-chat-scroll"]')!;
+ Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 500 });
+ act(() =>
+ root?.render(
+ strategy.render({
+ ...input,
+ segments: { historyVirtualized: [item], historyMounted: [userMessage(1)], liveHead: [] },
+ boundary: { ...input.boundary, hasVirtualizedHistory: true },
+ }),
+ ),
+ );
+ expect(container.querySelector('[data-history-row-id="message-0"]')).toBe(row);
+ expect(row.isConnected).toBe(true);
+ expect(row.querySelector("button")!.textContent).toBe("Loaded");
+ act(() =>
+ root?.render(
+ strategy.render({
+ ...input,
+ segments: {
+ historyVirtualized: [{ ...item, text: "grown" }, userMessage(1)],
+ historyMounted: [userMessage(2)],
+ liveHead: [],
+ },
+ boundary: { ...input.boundary, hasVirtualizedHistory: true },
+ }),
+ ),
+ );
+ expect(
+ (container.querySelector('[data-history-row-id="message-1"]') as HTMLElement).style.transform,
+ ).toBe("translateY(120px)");
+ expect(row.querySelector("button")!.textContent).toBe("Loaded");
+ });
+
it("keeps the timeline width stable with an overlay scrollbar", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
container = document.createElement("div");
diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx
index 25e7a313d2..97490c2a17 100644
--- a/packages/app/src/agent-stream/strategy-web.tsx
+++ b/packages/app/src/agent-stream/strategy-web.tsx
@@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
+import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
@@ -76,6 +77,8 @@ const historyStartSlotStyle: CSSProperties = {
flexShrink: 0,
};
+const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" };
+
const streamRowStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
@@ -268,6 +271,23 @@ function isScrollContainerOverscrolledPastBottom(
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
}
+// Position updates do not change a row's content. Keep renderer work separate
+// from virtual measurements while retaining the same component across lanes.
+const StreamRowContent = React.memo(function StreamRowContent({
+ item,
+ index,
+ items,
+ render,
+}: {
+ revision?: StreamRenderInput["liveHeadRowRevision"];
+ item: StreamItem;
+ index: number;
+ items: StreamItem[];
+ render: StreamRenderInput["renderers"]["renderHistoryVirtualizedRow"];
+}) {
+ return render(item, index, items);
+});
+
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const {
segments: inputSegments,
@@ -287,7 +307,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
olderHistoryProgressKey,
scrollEnabled,
contentMaxWidth,
- imageContext,
isMobileBreakpoint,
} = props;
const historyVirtualized = useRevisedHistoryRows(
@@ -425,13 +444,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
enabled: shouldUseVirtualizer,
+ initialRect: {
+ width: scrollContainerRef.current?.clientWidth ?? 0,
+ height: scrollContainerRef.current?.clientHeight ?? 0,
+ },
getScrollElement: () => scrollContainerRef.current,
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
estimateSize: (index: number) => {
const row = segments.historyVirtualized[index];
const measured = row && measuredRowHeights.current.get(row.id);
if (measured && measured.width === contentWidth) return measured.height;
- return row ? estimateStreamItemHeight({ item: row, contentMaxWidth, imageContext }) : 120;
+ return row ? estimateStreamItemHeight({ item: row, contentMaxWidth }) : 120;
},
observeElementOffset: observeVirtualOffset,
rangeExtractor,
@@ -536,25 +559,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scheduler.schedule();
});
- // ResizeObserver runs before paint. Batch measurements and commit React's new
- // positions here so a resized image cannot overlap rows for an extra frame.
- // Use resizeItem exclusively; measureElement would install a second observer.
+ const measureVirtualizedRows = useStableEvent(() => {
+ let resized = false;
+ for (const element of contentRef.current?.querySelectorAll(
+ "[data-history-row-id][data-index]",
+ ) ?? []) {
+ const index = Number(element.dataset.index);
+ const height = measureVirtualElement(element, undefined, rowVirtualizer);
+ if (rowVirtualizer.measurementsCache[index]?.size === height) continue;
+ rowVirtualizer.resizeItem(index, height);
+ resized = true;
+ }
+ return resized;
+ });
+
+ // Measure the committed DOM rather than queued observer entries: flushSync can
+ // also commit an image's pending intrinsic dimensions, invalidating those entries.
const virtualRowObserver = useMemo(
() =>
- new ResizeObserver((entries) => {
+ new ResizeObserver(() => {
if (!isActiveRef.current) return;
flushSync(() => {
- for (const entry of entries) {
- const element = entry.target as HTMLElement;
- if (!element.isConnected) continue;
- rowVirtualizer.resizeItem(
- Number(element.dataset.index),
- measureVirtualElement(element, entry, rowVirtualizer),
- );
- }
+ measureVirtualizedRows();
});
}),
- [rowVirtualizer],
+ [measureVirtualizedRows],
);
const measureVirtualizedRowElement = useCallback(
(node: HTMLDivElement | null) => {
@@ -731,8 +760,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Apply row positions and their scroll correction in the same paint, including
// the ordinary-to-virtualized handoff and measurements of newly mounted rows.
useLayoutEffect(() => {
+ // Commit positions for every resized row before accepting reading geometry.
+ // Child layout can change during this commit, ahead of ResizeObserver delivery.
+ if (!isActive || measureVirtualizedRows()) return;
reconcileReadingPosition();
- }, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]);
+ }, [
+ contentWidth,
+ isActive,
+ segments,
+ virtualRows,
+ measureVirtualizedRows,
+ reconcileReadingPosition,
+ ]);
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
cancelPendingStickToBottom();
@@ -915,6 +954,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
evaluateHistoryStart();
}
const observer = new ResizeObserver(() => {
+ flushSync(() => {
+ measureVirtualizedRows();
+ });
const nextGeometry = getObservedViewportGeometry(scrollContainer);
if (pendingResumeGeometryCheckRef.current) {
pendingResumeGeometryCheckRef.current = false;
@@ -936,14 +978,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) {
return;
}
- scheduleStickToBottom();
+ // ResizeObserver runs before paint. Following in the next frame exposes
+ // the newly grown row at the old offset for one painted frame.
+ forceStickToBottom();
});
observer.observe(scrollContainer);
if (contentNode) {
observer.observe(contentNode);
}
+ // Paced reveal commits inside a message without rerendering this viewport.
+ // Observe those commits too, so the next frame sees the grown content at the
+ // bottom instead of waiting for the resize notification after animation frames.
+ const contentChanges = new MutationObserver(() => {
+ if (isActiveRef.current && followOutputRef.current) forceStickToBottom();
+ });
+ if (contentNode)
+ contentChanges.observe(contentNode, { childList: true, characterData: true, subtree: true });
return () => {
observer.disconnect();
+ contentChanges.disconnect();
};
}, [
reconcileReadingPosition,
@@ -951,8 +1004,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
isActive,
reportReadingPosition,
scheduleHistoryStartPrependSettle,
- scheduleStickToBottom,
+ forceStickToBottom,
updateScrollMetrics,
+ measureVirtualizedRows,
]);
useLayoutEffect(() => {
@@ -1171,13 +1225,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}),
[],
);
- const virtualRowsContainerStyle = useMemo((): CSSProperties => {
- return {
- position: "relative",
- width: "100%",
+ const virtualRowsContainerStyle = useMemo(
+ (): CSSProperties => ({
height: virtualTotalSize,
- };
- }, [virtualTotalSize]);
+ flexShrink: 0,
+ }),
+ [virtualTotalSize],
+ );
const renderVirtualRowStyle = useCallback(
(start: number): CSSProperties => ({
position: "absolute",
@@ -1198,12 +1252,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
data-message-id={getStreamItemMessageId(item)}
style={streamRowStyle}
>
- {renderHistoryMountedRow(item, index, segments.historyMounted)}
+
));
}, [renderHistoryMountedRow, segments.historyMounted]);
const liveHeadRows = useMemo(() => {
- void liveHeadRowRevision;
return segments.liveHead.map((item, index) => (
- {renderLiveHeadRow(item, index, segments.liveHead)}
+
));
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
@@ -1241,9 +1305,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
!liveAuxiliary;
return (
-
+
{historyStartSlot}
- {shouldUseVirtualizer ? (
-
- {virtualRows.map((virtualRow) => {
+
+
+ {[
+ ...virtualRows.map((virtualRow) => {
const item = segments.historyVirtualized[virtualRow.index];
- if (!item) {
- return null;
- }
+ if (!item) return null;
return (
- {renderHistoryVirtualizedRow(
- item,
- virtualRow.index,
- segments.historyVirtualized,
- )}
+
);
- })}
-
- ) : null}
- {mountedRows}
+ }),
+ ...mountedRows,
+ ]}
+
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts
index 3b77f6b831..5f43f8f6b1 100644
--- a/packages/app/src/agent-stream/strategy.ts
+++ b/packages/app/src/agent-stream/strategy.ts
@@ -1,4 +1,3 @@
-import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
@@ -95,7 +94,6 @@ export interface StreamRenderInput {
forwardListContentContainerStyle: StyleProp;
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
contentMaxWidth: number;
- imageContext?: AssistantImageContext;
}
export interface ResolveStreamRenderStrategyInput {
diff --git a/packages/app/src/agent-stream/stream-tick.bench.ts b/packages/app/src/agent-stream/stream-tick.bench.ts
new file mode 100644
index 0000000000..dc1fecb881
--- /dev/null
+++ b/packages/app/src/agent-stream/stream-tick.bench.ts
@@ -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 });
+ });
+});
diff --git a/packages/app/src/agent-stream/top-overlay.ts b/packages/app/src/agent-stream/top-overlay.ts
new file mode 100644
index 0000000000..24cd7f9592
--- /dev/null
+++ b/packages/app/src/agent-stream/top-overlay.ts
@@ -0,0 +1,14 @@
+import { createContext, useContext } from "react";
+import { isNative } from "@/constants/platform";
+
+/** The native inverted viewport reserves visual-top clearance; web stays in flow. */
+export const supportsStreamTopOverlay = isNative;
+
+const StreamTopOverlayContext = createContext(0);
+
+/** Visual-top clearance supplied by the containing layout, in points. */
+export const StreamTopOverlayProvider = StreamTopOverlayContext.Provider;
+
+export function useStreamTopOverlayInset(): number {
+ return useContext(StreamTopOverlayContext);
+}
diff --git a/packages/app/src/agent-stream/use-stream-history-window.ts b/packages/app/src/agent-stream/use-stream-history-window.ts
index 42168f052d..9874158dcc 100644
--- a/packages/app/src/agent-stream/use-stream-history-window.ts
+++ b/packages/app/src/agent-stream/use-stream-history-window.ts
@@ -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) {
diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx
index 12f628da4f..cd7aa42b70 100644
--- a/packages/app/src/agent-stream/view.tsx
+++ b/packages/app/src/agent-stream/view.tsx
@@ -218,12 +218,13 @@ function renderListEmptyComponent(input: {
);
}
-// History rows sit inside FlatList cells that rerender on every data change (RN recreates each
-// CellRenderer with a fresh ref and, in a newest-first list, a shifted index). This boundary is
+// Live and retained rows use the same boundary so completion preserves the message
+// subtree, including measured diagrams and selections. FlatList recreates history
+// cells with fresh refs and shifted indexes on data changes. This boundary is
// what stops that churn: a row renders again only when its stream item identity, its layout item
// identity, or the renderer itself changes. Item identity is the revision signal the strategy
// already uses (`useRevisedHistoryRows` clones items whose content or display state changed).
-const HistoryStreamRow = memo(function HistoryStreamRow({
+const StreamRow = memo(function StreamRow({
layoutItem,
renderStreamItem,
}: {
@@ -234,7 +235,7 @@ const HistoryStreamRow = memo(function HistoryStreamRow({
return <>{renderStreamItem(layoutItem)}>;
});
-function renderHistoryStreamItem(input: {
+function renderStreamRow(input: {
item: StreamItem;
layoutItemById: Map;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
@@ -244,7 +245,7 @@ function renderHistoryStreamItem(input: {
return null;
}
return (
- ;
- renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
-}): ReactNode {
- const layoutItem = input.layoutItemById.get(input.item.id);
- if (!layoutItem) {
- return null;
- }
- return input.renderStreamItem(layoutItem);
-}
-
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
@@ -607,16 +596,21 @@ const AgentStreamViewComponent = forwardRef
- 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 +620,7 @@ const AgentStreamViewComponent = forwardRef
- renderHistoryStreamItem({
+ renderStreamRow({
item,
layoutItemById: layoutHistoryItemById,
renderStreamItem,
@@ -1045,7 +1040,7 @@ const AgentStreamViewComponent = forwardRef
- renderLiveHeadStreamItem({
+ renderStreamRow({
item,
layoutItemById: layoutLiveHeadItemById,
renderStreamItem,
@@ -1104,7 +1099,7 @@ const AgentStreamViewComponent = forwardRef