mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e53e93ed26 | ||
|
|
c8bd4717c9 | ||
|
|
6c1b48d296 | ||
|
|
1a626230d9 | ||
|
|
4b285a113d | ||
|
|
4281372bee | ||
|
|
e79e826e23 | ||
|
|
4928500e1f | ||
|
|
1e2ab3d596 | ||
|
|
2716bd96de | ||
|
|
5abfdf7107 | ||
|
|
6355b339c8 | ||
|
|
8b9c42c266 | ||
|
|
a68dc55490 | ||
|
|
3ed57e9dd7 | ||
|
|
e9f897a66a | ||
|
|
b0a8a5db6d | ||
|
|
412550c36f | ||
|
|
e6064bb374 | ||
|
|
59be8975a4 | ||
|
|
cdd7800fed | ||
|
|
902d43da7c | ||
|
|
92c8c55f31 | ||
|
|
b920243c74 | ||
|
|
9667ec5576 | ||
|
|
72955cef60 | ||
|
|
8dfdb479ab | ||
|
|
5d1a2c10f8 | ||
|
|
a91bb4d406 | ||
|
|
15997a06d0 | ||
|
|
2d5b8eb1fd | ||
|
|
ac775a2904 | ||
|
|
0e7f452c2f | ||
|
|
cab5eedfe3 | ||
|
|
4407283a13 | ||
|
|
4f7813d3aa | ||
|
|
4991efddf8 | ||
|
|
fcbfb4f0d3 | ||
|
|
6221b0c8b7 | ||
|
|
bfbd4d5175 | ||
|
|
52b84b7835 | ||
|
|
d68271565a | ||
|
|
ce7b14a143 | ||
|
|
c7e137d4b4 | ||
|
|
d37262be56 | ||
|
|
0952ddbd4b | ||
|
|
67628b48fe | ||
|
|
eba5bdf810 | ||
|
|
66676d49e6 | ||
|
|
064243288b | ||
|
|
a880c9619d | ||
|
|
6740d41fab | ||
|
|
ca6b54fc2e | ||
|
|
4cd23a8f17 | ||
|
|
3084f32852 | ||
|
|
ce8196f6c5 | ||
|
|
40b95f70f4 | ||
|
|
93b9ac9ded | ||
|
|
eb8b5b8f94 | ||
|
|
51b9ad7695 | ||
|
|
1c4400194a | ||
|
|
1502b7f90f | ||
|
|
16f688b726 | ||
|
|
b507fc1986 | ||
|
|
e546497a21 |
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.2 - 2026-11-05
|
||||
|
||||
### Added
|
||||
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
|
||||
### Improved
|
||||
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Replaced the "Loading…" text in Providers settings with skeleton rows ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Changed controls to two sizes: default controls are 32px tall on desktop and 44px on phones, and compact controls are 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Changed first launch to go from the loading screen straight into setup instead of showing the empty home first ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||
- Changed dialogs on desktop and web to fade in over about 160ms ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||
- Changed modal and sheet backgrounds to match the screen background ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- Changed documentation links in Settings sections to a `Label ↗` link beside the section title ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
|
||||
## 0.15.1 - 2026-11-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run `Get-CimInstance` ([#8810](https://github.com/getpaseo/paseo/pull/8810))
|
||||
|
||||
## 0.15.0 - 2026-11-03
|
||||
|
||||
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.2 - 2026-11-05
|
||||
|
||||
### 新增
|
||||
|
||||
- 在 Paseo 桌面版和网页版新增首次运行引导,依次带新用户设置主题、提供商、项目、Agent 编排和移动端应用,并可通过“设置 → 通用 → Show setup again”重新打开 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 在引导的“项目”步骤中,根据你运行过 Claude Code、Codex、OpenCode 或 Pi 的文件夹推荐项目 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
|
||||
### 改进
|
||||
|
||||
- 将主题选择器改为带各主题预览的图块网格 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 将“提供商”设置中的“Loading…”文字替换为骨架占位行 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 将控件统一为两种尺寸:默认控件在桌面端高 32px、在手机上高 44px,紧凑控件高 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 将首次启动改为从加载界面直接进入引导,不再先显示空白的主页 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||
- 将桌面端和网页版的对话框改为约 160 毫秒的淡入 ([#8824](https://github.com/getpaseo/paseo/pull/8824))
|
||||
- 将弹窗和面板的背景改为与屏幕背景一致 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
- 将“设置”各分区的文档链接改为分区标题旁的 `Label ↗` 链接 ([#8363](https://github.com/getpaseo/paseo/pull/8363))
|
||||
|
||||
## 0.15.1 - 2026-11-05
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复终端工作进程无法读取自身进程标识时 daemon 无法启动的问题;此前在 Windows PowerShell 5.1 无法运行 `Get-CimInstance` 的 Windows 机器上,Paseo 桌面版因此没有可用的 daemon ([#8810](https://github.com/getpaseo/paseo/pull/8810))
|
||||
|
||||
## 0.15.0 - 2026-11-03
|
||||
|
||||
daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。
|
||||
|
||||
@@ -34,6 +34,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md) | The binding table, per-platform key sources, badges vs routing, iOS `UIKeyCommand` limits |
|
||||
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
|
||||
@@ -54,6 +55,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
| [docs/agent-stream-disclosure.md](docs/agent-stream-disclosure.md) | Expand/collapse in the chat timeline — hold contract, native anchor sequence, frame spacing, render-window cost |
|
||||
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
|
||||
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
|
||||
+67
-14
@@ -136,6 +136,12 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
|
||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
||||
normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`.
|
||||
This is informational ancestry for future workspace grouping. It does not change placement,
|
||||
visibility, status, or archive behavior. Archiving a parent workspace preserves its children's
|
||||
records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is
|
||||
never inferred from directories, projects, or Git branches.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
|
||||
|
||||
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
|
||||
@@ -183,23 +189,42 @@ Some providers can create their own child sessions inside one provider runtime.
|
||||
|
||||
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
|
||||
|
||||
## Background workspaces
|
||||
## Background visibility
|
||||
|
||||
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
|
||||
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
|
||||
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
|
||||
change it. New workspaces default to false, inherit the caller workspace's value when an agent
|
||||
creates one without an override, and accept explicit true or false.
|
||||
An agent is effectively background when it or its workspace is background:
|
||||
`agent.background || workspace.background`. Background controls discovery only. Background agents
|
||||
and the agents in background workspaces are ordinary durable agents: provider history, plugin
|
||||
hooks, parentage, finish notifications to a caller, archive, and restart recovery keep their
|
||||
normal behavior. Both flags are fixed at creation.
|
||||
|
||||
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
|
||||
including History requests, exclude background work; `filter.includeBackground` includes it.
|
||||
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
|
||||
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
|
||||
- **Agent.** `create_agent_request.background` marks the agent. Use it for helpers the user does
|
||||
not watch, such as a plugin's classifier in the user's open workspace. A background agent never
|
||||
raises attention, adds nothing to its workspace's status bucket or Last prompt time, and stays
|
||||
out of tabs, the parent's subagent track and agent lists in the app. A tab opened by id still
|
||||
works.
|
||||
- **Workspace.** `workspace.background` hides the workspace from the sidebar unless **Show
|
||||
background** is on.
|
||||
- **Inheritance.** When `background` is omitted, an agent created by a caller agent takes the
|
||||
caller's own `background`. A new workspace takes the caller's effective background. A request
|
||||
that creates its workspace applies `background` to both the agent and the workspace. Explicit
|
||||
true or false always wins.
|
||||
|
||||
`AgentDirectory` (`packages/server/src/server/agent/directory.ts`) owns visibility for lists.
|
||||
Default workspace and agent lists, including History requests, exclude background work;
|
||||
`includeBackground` includes it. Exact-ID operations bypass discovery filtering. The app
|
||||
replicates inclusively and filters at each surface.
|
||||
|
||||
Sources without the `background_agents` hello capability get workspace-only visibility on
|
||||
inclusive reads and subscriptions: agents in background workspaces, but no agent-level background
|
||||
agents. Older apps replicate inclusively and auto-open a tab for every root agent, so a classifier
|
||||
run would otherwise open a tab each turn. That visibility has its own directory sync generation.
|
||||
|
||||
Sequenced directories keep complete canonical collections. A filtered read must never replace
|
||||
those collections with its subset. Inclusive and default observations have distinct cursor
|
||||
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
|
||||
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
|
||||
Clients gate background workspaces and inclusive discovery on `features.backgroundWorkspaces`.
|
||||
They gate a background agent placed in an existing workspace or under a caller on
|
||||
`features.backgroundAgents`.
|
||||
|
||||
### Private daemon helpers
|
||||
|
||||
@@ -234,6 +259,27 @@ archived workspace. History navigation must not infer workspace lifecycle from `
|
||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||
|
||||
Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up.
|
||||
The daemon runs one archive lane per workspace, shared by every connection
|
||||
(`packages/server/src/server/workspace-archive-operations.ts`):
|
||||
|
||||
| A request arrives while | Result |
|
||||
| ------------------------ | --------------------------------------------------------- |
|
||||
| an archive runs: archive | joins the running archive |
|
||||
| an archive runs: undo | aborts the archive, waits for it to settle, then restores |
|
||||
| an undo runs: archive | waits for the undo, then archives |
|
||||
| an undo runs: undo | joins the running undo |
|
||||
|
||||
A request takes its place in the lane before its first `await`. Arrival order decides, so an undo
|
||||
sent right behind its archive still cancels it.
|
||||
|
||||
An aborted archive stops at its next checkpoint and reports `canceled`; the undo unarchives only
|
||||
the agents that archive itself archived. Deleting the worktree directory is the point of no
|
||||
return: an undo after it recreates the worktree through workspace recovery. The client shows or
|
||||
hides the row by the latest local intent, so a late remove or upsert from a superseded request
|
||||
does not flip it back. Tabs and layout are purged only once the undo window closes with the
|
||||
archive committed.
|
||||
|
||||
History navigation opens the selected agent without changing either archive state. Workspace
|
||||
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
|
||||
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
|
||||
@@ -286,9 +332,9 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
|
||||
|
||||
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
|
||||
|
||||
A finished workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
`finished` attention on its newest eligible workspace-root agent without sending a new completion
|
||||
notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is
|
||||
`error` and `finished` otherwise, without sending a new notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
|
||||
Attention is set by the agent finishing or failing and cleared by the client's
|
||||
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
|
||||
@@ -297,6 +343,13 @@ untouched. Forging either makes a background resume look like the user read a wo
|
||||
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
|
||||
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
|
||||
|
||||
The `failed` bucket follows unread `error` attention, not lifecycle status. An agent stays `error`
|
||||
until its next turn starts, so reading the chat dismisses the red dot the same way it dismisses the
|
||||
green one. For the same reason every new failure raises `error` attention, including a failed reload
|
||||
that leaves the lifecycle at `error`, and replaces unread `finished` attention; otherwise the failed
|
||||
agent would show the green dot or none. Subagents track marks are the exception: they follow the
|
||||
child's lifecycle, because the track is where a failed child stays visible.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# Agent stream disclosures
|
||||
|
||||
Expanding or collapsing something in the chat timeline (a turn's summary line, a tool-call group,
|
||||
a tool call's inline details, a todo card) keeps the tapped trigger at the same screen y in the
|
||||
first painted frame. No animation.
|
||||
|
||||
## The contract
|
||||
|
||||
A disclosure component knows nothing about the list. It wraps its state change:
|
||||
|
||||
```tsx
|
||||
const hold = useDisclosureHold();
|
||||
const handleToggle = () => hold(() => setExpanded((value) => !value));
|
||||
```
|
||||
|
||||
`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's
|
||||
`holdRow(rowId, apply)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a timeline row,
|
||||
`apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
|
||||
|
||||
Following output:
|
||||
|
||||
- A hold always detaches the bottom anchor controller (`holdForDisclosure`).
|
||||
- When the hold ends, the controller re-sticks only if the list sits at its end and no user scroll
|
||||
owns the viewport (`releaseDisclosure({ isAtEnd })`). Expanding at the bottom therefore leaves
|
||||
the list detached with the scroll-to-bottom pill showing; collapsing back to the end resumes
|
||||
following.
|
||||
- Scroll events during the hold never re-stick: the correction is a programmatic scroll that
|
||||
would otherwise read as "back at the bottom".
|
||||
|
||||
The trigger can only stay put when the list has scroll room for the change. When the loaded
|
||||
content fits the viewport, or a collapse at the end would need an offset past the end, the list
|
||||
ends at its end and the trigger moves by the difference.
|
||||
|
||||
## Web
|
||||
|
||||
`holdRow` pins the row in the reading anchor and runs `apply`; the next reading reconcile restores
|
||||
the row's top after layout, before paint (`strategy-web.tsx`).
|
||||
|
||||
## Native
|
||||
|
||||
On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do
|
||||
not hold. Summary lines and todo cards expand inline.
|
||||
|
||||
The timeline is a LegendList in forward order: one `data` array of history rows followed by the
|
||||
live head, oldest first (`strategy-native.tsx`). LegendList keeps a row in place across a size
|
||||
change in the commit that makes it, so a hold needs no anchor neighbour, native child index or
|
||||
render window stretch. The inverted FlatList it replaced needed all three, and three commits per
|
||||
toggle; its applied commit took up to 3.5 s of JS on a long turn.
|
||||
|
||||
### Who owns the offset
|
||||
|
||||
One owner keeps the scroll offset at any moment:
|
||||
|
||||
| State | Owner |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
|
||||
| Following the end | The end pin (`native-end-pin.tsx`). |
|
||||
| Disclosure hold | LegendList's `maintainVisibleContentPosition`, restoring only the held row. |
|
||||
| User scroll | The finger. The controller detaches at drag start. |
|
||||
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
|
||||
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
|
||||
|
||||
The end pin is a zero-height view at the bottom of the content and the content's first native
|
||||
child, with React Native's `maintainVisibleContentPosition` set to `{ minIndexForVisible: 0 }`. The
|
||||
native correction runs on the UI thread around each mount, so a row that grows or lands at the end
|
||||
scrolls in the frame it lays out. The controller scrolls only for a viewport resize (the composer
|
||||
growing or shrinking) and an explicit jump, both through the native `scrollToEnd`.
|
||||
|
||||
Do not use LegendList's `maintainScrollAtEnd` instead. It scrolls from JS after the mount to the end
|
||||
its JS sizes predict: growth paints below the viewport for a frame, and the scroll lands short of
|
||||
the native end, which the controller reads as the user scrolling away.
|
||||
|
||||
The keyboard never changes the list's size, padding or offset. `KeyboardTranslateView` moves the list
|
||||
and the composer together on the UI thread, and the composer viewport clips the top of the list
|
||||
while the keyboard is open. Do not add a keyboard-sized inset to keep that content reachable: it can
|
||||
only commit after the motion, and while detached or holding nothing corrects for it, so every row
|
||||
jumps by the keyboard height after the keyboard settles.
|
||||
|
||||
### The hold
|
||||
|
||||
`holdRow(rowId, apply)` runs in the toggle's event:
|
||||
|
||||
1. Mark the row held and detach the controller (`holdForDisclosure`). The pin goes away and
|
||||
LegendList's `maintainVisibleContentPosition` takes over, restoring only the held row
|
||||
(`shouldRestorePosition`). Upstream LegendList anchors only rows from the first fully visible
|
||||
one down, and a size change with no data change keeps the first row in view whatever
|
||||
`shouldRestorePosition` says. A row cut off at the top would then let the row below it hold
|
||||
instead, moving the trigger by the row's size change. The LegendList patch counts rows cut off
|
||||
at the top as in view and applies `shouldRestorePosition` to size changes too.
|
||||
2. Run `apply`.
|
||||
3. Call the row's `useSyncLayout` trigger. The toggle changes the row from its own state, so
|
||||
LegendList's container does not re-render and would learn the new height from `onLayout` a task
|
||||
later, after the change painted. The trigger puts the measurement and the corrected positions
|
||||
in the toggle's own commit.
|
||||
|
||||
The hold ends two frames after the last item size change, since the list measures the toggled row
|
||||
and any rows it inserted over several frames (`native-disclosure-hold.ts`). The controller then
|
||||
re-sticks only if the list is within 1 px of its end, the same distance web resumes from.
|
||||
|
||||
### User scroll
|
||||
|
||||
The controller detaches at `onScrollBeginDrag`, so lines that stream in during a drag grow below
|
||||
the rows under the finger. The detach reaches native a JS round trip after the drag begins, so for
|
||||
those frames the pin is still mounted. The scroll view subclasses skip the pin's correction while
|
||||
the user drags (see [scroll view corrections](#scroll-view-corrections)). They skip only the pin's:
|
||||
in the detached state LegendList uses the same native correction, and a history page that lands
|
||||
mid-drag needs it. They key on the drag, not the touch, so a tap or long press while following
|
||||
keeps following.
|
||||
|
||||
Following resumes only for a gesture that ends within 1 px of the end, either when the finger lifts
|
||||
or when momentum stops. A release past the end counts: iOS bounces back to the end, and lines that
|
||||
land during the bounce must keep scrolling in. A release anywhere short of the end stays detached
|
||||
with the scroll-to-bottom pill, so the rows the user let go of never move.
|
||||
|
||||
### Draw distance
|
||||
|
||||
`drawDistance` is 2000 px on each side of the viewport. A fling over rows that were never measured
|
||||
blanks the viewport until LegendList renders and measures them. Twelve flings over a chat with a
|
||||
15,000 px turn expanded, Android emulator, dev build:
|
||||
|
||||
| List | Blank viewport | Janky frames | p50 / p90 / p99 |
|
||||
| ----------------------------- | -------------------------- | ------------ | ---------------- |
|
||||
| Inverted FlatList | 1.3 to 1.7 s over 5 flings | 10.8% | 27 / 48 / 109 ms |
|
||||
| LegendList, drawDistance 500 | 1.0 to 1.5 s per fling | | |
|
||||
| LegendList, drawDistance 2000 | none | 1.7% | 24 / 32 / 48 ms |
|
||||
|
||||
With every turn collapsed, flinging from a cold open into history did not blank at any value from
|
||||
500 to 2000, and jank followed host load (19 to 40 on 16 cores) rather than draw distance. Measure
|
||||
1000 and 1500 on the expanded chat, on a host under load 10, before lowering it.
|
||||
|
||||
### LegendList patch
|
||||
|
||||
`patches/@legendapp+list+3.7.1.patch` carries ten changes for the timeline. Its header lists
|
||||
them by purpose, and
|
||||
[legend-list #593](https://github.com/LegendApp/legend-list/issues/593) reports each upstream.
|
||||
Drop a hunk when the installed LegendList ships its fix.
|
||||
|
||||
### Scroll view corrections
|
||||
|
||||
`modules/paseo-scroll` replaces React Native's scroll view on both platforms with a subclass:
|
||||
Android's `PaseoScrollView` and iOS's `PaseoScrollViewComponentView`.
|
||||
`VisibleContentPositionClampTest` covers Android; iOS has no native test harness.
|
||||
|
||||
**Content clamp.** A mount that shrinks the content while `maintainVisibleContentPosition` is set
|
||||
applies the shrink twice on both platforms: the scroll view clamps its offset to the new end during
|
||||
the mount, and React Native then adds the anchor's movement to the clamped offset
|
||||
([RN #58578](https://github.com/react/react-native/issues/58578), fix in
|
||||
[PR #58695](https://github.com/react/react-native/pull/58695)). A turn that folds while the list
|
||||
follows its end lands above the end. Android defers the clamp to the pre-draw pass, and iOS restores
|
||||
the pre-mount offset for the adjustment and clamps after it.
|
||||
|
||||
**Removed anchor (iOS).** A hold removes the end pin in the same mount that opens the tapped
|
||||
disclosure, and React Native 0.86 on iOS moves the offset by the removed view's frame. The iOS
|
||||
subclass keeps the offset. Android keeps a removed view's last frame, so it keeps the offset already.
|
||||
React Native 0.88 skips the adjustment for a removed anchor
|
||||
([PR #57294](https://github.com/react/react-native/pull/57294)).
|
||||
|
||||
**End pin during a drag.** Both subclasses recognize the pin by its native ID, `paseo-end-pin`,
|
||||
and skip its correction while the user drags ([user scroll](#user-scroll)). This one has no React
|
||||
Native counterpart; it stays while the timeline uses the pin.
|
||||
|
||||
Remove the content clamp and removed-anchor overrides once the installed React Native includes
|
||||
their fixes.
|
||||
@@ -34,10 +34,10 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
- **A history row re-renders only when its item or layout item identity changes.** The inverted
|
||||
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
|
||||
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
|
||||
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
|
||||
- **A history row re-renders only when its item or layout item identity changes.** Every
|
||||
coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every
|
||||
mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a
|
||||
phone, 100–250 ms of JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
|
||||
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
|
||||
or breakpoint changed, and `StreamRow` memoizes on both. Every viewport runs its history
|
||||
through that hook; the web viewport once skipped it and history hosts of a live tool group went
|
||||
|
||||
+11
-7
@@ -174,14 +174,18 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
|
||||
|
||||
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
||||
|
||||
## Inverted timeline selection
|
||||
## Scroll view corrections
|
||||
|
||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
||||
child-reveal scroll calculations when the view's transform matrix inverts the vertical axis. React
|
||||
Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotation with positive
|
||||
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
||||
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
|
||||
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
|
||||
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
|
||||
lists the corrections and the React Native issues they work around. Its instrumentation test,
|
||||
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
|
||||
|
||||
```bash
|
||||
cd packages/app/android
|
||||
./gradlew :paseo-scroll:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
||||
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
||||
|
||||
@@ -777,6 +777,8 @@ The built-in `claude` provider appends concrete model IDs from `~/.claude/settin
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
|
||||
Paseo's effort selector does not inherit `effortLevel` from Claude's settings.json. Select an effort in Paseo to choose a specific value.
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||
|
||||
+5
-1
@@ -26,6 +26,10 @@ checkout root. They intentionally differ for an exact subproject inside a worktr
|
||||
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
|
||||
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
|
||||
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
|
||||
An explicit base selection is stored in the checkout's Git-directory Paseo metadata and
|
||||
mirrored to `baseBranch` in every workspace record sharing that checkout. The record keeps
|
||||
recovery possible after the checkout metadata is deleted. A newly opened workspace inherits
|
||||
the checkout's selected base; separate worktrees keep separate bases.
|
||||
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
|
||||
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
|
||||
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
|
||||
@@ -470,7 +474,7 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
||||
| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. |
|
||||
| `baseBranch` | `string \| null` | Current base retained across archive and restore. Explicit selections and branch-off creation store exact refs; older records may store branch names. Null means no recorded base. |
|
||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
|
||||
+12
-4
@@ -68,9 +68,15 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px in the compact layout, so every phone control is a full touch target; the breakpoint, not the caller, makes that choice. `sm` is 28px with `fontSize.sm` labels and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
|
||||
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 in the compact layout, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`) and its circle reaches into the gaps, so `TOOLBAR_CONTROL_GAP`, 20pt, is the visible space between glyphs. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
|
||||
|
||||
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`).
|
||||
|
||||
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
|
||||
|
||||
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 where controls are touch-sized, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
@@ -138,7 +144,7 @@ Things align to their glyphs, not to their boxes. A row's leading icon, its titl
|
||||
|
||||
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
|
||||
|
||||
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
|
||||
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`).
|
||||
|
||||
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
|
||||
|
||||
@@ -150,7 +156,7 @@ One row off the rail makes the whole card look unconsidered.
|
||||
|
||||
Compact-first. The small case is designed; the large case adds chrome around it.
|
||||
|
||||
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
|
||||
Use `md: 600` for the compact/wide layout boundary, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint; touch sizing comes from the control sizing gate in [Buttons](#4-buttons). Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
|
||||
|
||||
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
|
||||
|
||||
@@ -167,6 +173,8 @@ Electron window controls are top-corner obstructions, not a compact-layout condi
|
||||
|
||||
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
||||
|
||||
Each window edge has one safe-area owner. `WindowLayoutProvider` publishes the bottom inset that every surface reads through `useSafeAreaInsets()`: on iOS 27.1 and later it is UIKit's bottom-bar clearance, which refines the rectangular home-indicator inset to the screen's corners, and elsewhere the system inset. Surfaces apply that value once and never stack their own spacing under it; `WindowLayoutSurface` owns the horizontal insets the same way.
|
||||
|
||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||
|
||||
---
|
||||
|
||||
+11
-1
@@ -593,7 +593,17 @@ rerun passes only by landing elsewhere.
|
||||
|
||||
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
|
||||
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
|
||||
replace that version in the lockfile and report the URL to npm so they purge it.
|
||||
replace that version in the lockfile. If the dependent’s range cannot be changed, pin a clean
|
||||
version in root `overrides`. Report the URL to npm so they purge it.
|
||||
|
||||
A lockfile regeneration is itself how new bad versions enter: the pkijs fix re-resolved the
|
||||
electron-builder tree and pulled in `semver` 5.7.3 and 6.3.2, published two days earlier, and
|
||||
5.7.3 then failed the same way. After editing `overrides`, run `npm install --package-lock-only`
|
||||
and check that the changed `resolved` lines in `package-lock.json` are only the ones you meant.
|
||||
|
||||
A per-parent override such as `"tiny-async-pool": { "semver": "5.7.2" }` applies to that
|
||||
parent's whole subtree, so it can also downgrade a grandchild that needs a different major. Key
|
||||
the child by range (`{ "semver@^5": "5.7.2" }`) so only matching edges are replaced.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
|
||||
+2
-1
@@ -96,7 +96,8 @@ hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
|
||||
Back returns to that host's Plugins page.
|
||||
Back returns to that host's Plugins page. Its screen tabs change `screenId` with `router.setParams`,
|
||||
so switching tabs adds no stack entries.
|
||||
|
||||
Settings detail routes are separate siblings on purpose. Keep
|
||||
`settings/[section]`, the host routes, the projects index, and project detail as
|
||||
|
||||
@@ -157,8 +157,7 @@ and the stream keeps its existing flick-to-dismiss gesture.
|
||||
The dock's private viewport and capacity model are extracted from chat. The
|
||||
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
|
||||
terminal, explorer, and dialogs. Import only its public entry point: the provider,
|
||||
shift hooks, shift style, translate view, keyboard anchor, settled stream inset, and explorer padding
|
||||
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
|
||||
shift hooks, shift style, translate view, and keyboard anchor. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
|
||||
at its root mount point outside the portal host when changing dock hosts.
|
||||
|
||||
`KeyboardTranslateView` owns visual keyboard motion inside `@/keyboard/shift`. Android uses the
|
||||
@@ -171,15 +170,19 @@ animation. Keep this platform choice inside the keyboard module's private platfo
|
||||
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
|
||||
slide under the header on open and out from under it on close. Do not swap the
|
||||
dock to keyboard padding at rest. Changing its height creates either a blank
|
||||
band or a paused transition when the inverted list updates its viewport.
|
||||
band or a paused transition when the stream list updates its viewport.
|
||||
|
||||
The docked composer rests at the larger of the safe-area bottom and 16pt. With
|
||||
the keyboard open it keeps 16pt above the keyboard through the translate view's
|
||||
`keyboardGap`, which rises further than the keyboard's safe-area overlap. Do not
|
||||
grow the composer's bottom padding with the keyboard instead.
|
||||
|
||||
Do not animate a layout prop through the keyboard transition. On Android
|
||||
Fabric, each animated layout update clones the shadow tree and runs Yoga over
|
||||
the dock subtree. This measured about 10 ms per frame on the emulator and the
|
||||
mounted layouts arrived in bursts every 2–4 frames. A transform does not dirty
|
||||
layout. Preserve the translated dock's history scroll range with a far-end
|
||||
content inset on the inverted stream list. Update that inset only when keyboard
|
||||
motion settles; never drive it per frame.
|
||||
layout. The stream list gets no keyboard inset either; see
|
||||
[who owns the offset](agent-stream-disclosure.md#who-owns-the-offset).
|
||||
|
||||
The translated dock's height is not the composer's available height. Bound the
|
||||
composer against the stationary space below the header inside
|
||||
|
||||
+8
-12
@@ -97,19 +97,15 @@ Register the adapter in `defaultForgeRegistry` with:
|
||||
- `matchesHost` from manifest `cloudHosts`
|
||||
- `probeHost` when self-hosted/Enterprise detection is supported
|
||||
|
||||
Current change-request lookup uses two identities deliberately:
|
||||
Current change-request lookup matches the branch and repository for both open and
|
||||
terminal requests. Prefer an open request, then a terminal request at the local
|
||||
HEAD, then the most recently updated terminal request. A reused branch can show
|
||||
its previous request until a new one opens. Auto-archive separately requires an
|
||||
observed open-to-merged transition and a fresh exact match between checkout HEAD
|
||||
and the PR head, along with the existing workspace safety checks.
|
||||
|
||||
- An open PR/MR belongs to the checkout when its head branch and head repository
|
||||
match. Its remote head SHA may differ because the checkout can be ahead,
|
||||
behind, or contain commits that have not been pushed yet.
|
||||
- A merged or closed PR/MR belongs to the checkout only when its recorded head
|
||||
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
|
||||
selecting the newest terminal request by branch alone can silently attach an
|
||||
old promotion or feature request to new work.
|
||||
|
||||
Thread the checkout head SHA through adapter cache and poll identities as well
|
||||
as the lookup itself. Otherwise a commit made on the same branch can inherit the
|
||||
previous commit's cached terminal status until the cache expires.
|
||||
Keep checkout HEAD in adapter cache and poll identities so checkout changes
|
||||
refresh the facts used by archive eligibility.
|
||||
|
||||
Cloud hosts in the manifest are a bounded public-host list, not a self-host
|
||||
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
|
||||
|
||||
@@ -28,6 +28,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Background agent** — An agent hidden from default discovery and from app surfaces that announce agents: tabs, the subagent track, agent lists, workspace status and attention. It is otherwise an ordinary durable agent, addressable by id. An agent is effectively background when it or its workspace is background. Fixed at creation. Code: `background` on `AgentSnapshotPayload` and `create_agent_request`. See [agent-lifecycle.md](agent-lifecycle.md#background-visibility). Don't confuse with background work (provider shells and monitors) or the daemon's private internal helpers, which are never public ([agent-lifecycle.md](agent-lifecycle.md#private-daemon-helpers)).
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Keyboard shortcuts
|
||||
|
||||
Define shortcuts in [the shared binding table](../packages/app/src/keyboard/keyboard-shortcuts.ts). Platforms contribute key sources, not separate action maps, so matching and user rebinds stay consistent across runtimes. Add a shortcut through a binding; add platform support through a key source.
|
||||
|
||||
## Badges and routing
|
||||
|
||||
Routing runs whenever the app mounts its key sources, independently of layout.
|
||||
Use `useHardwareKeyboardPresent()` for hints and the Shortcuts settings section.
|
||||
iOS reads `GCKeyboard.coalesced` and connection notifications. Android watches input
|
||||
devices and hardware keyboard configuration. Web starts with a fine hover pointer and recognizes the first modified
|
||||
keydown, including on touch devices. Compact Electron keeps its bindings.
|
||||
|
||||
Keyboard navigation needs the sidebar's filtered workspace model even when its visual
|
||||
panel is hidden. Retain that model while keyboard navigation is active; gating it on
|
||||
panel visibility leaves number shortcuts with no targets after a compact startup.
|
||||
|
||||
Composer send-key customization remains a separate capability. Native retains its
|
||||
existing Return submission path; advertising its web-only customization would expose
|
||||
settings the native composer does not implement.
|
||||
|
||||
## Native sources
|
||||
|
||||
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
|
||||
that must bypass the focused text control: composer Return and the top overlay's
|
||||
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
press is still observed by the window.
|
||||
|
||||
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||
`UITextView` first responder. Both `UIWindow.sendEvent` and the root controller's
|
||||
`pressesBegan` received Cmd+B, Cmd+P and Escape. With Escape registered as a
|
||||
`UIKeyCommand`, the window observed it and the command handler also ran.
|
||||
|
||||
Simulator reserves Cmd+K for Toggle Software Keyboard. Injecting it produced
|
||||
HID 184 with empty characters rather than K; plain K, Cmd+B and Cmd+P arrived
|
||||
normally. Keep Simulator input capture off: Send Keyboard Input to Device
|
||||
prevented synthetic AppleScript input from arriving, including plain text.
|
||||
For simulator QA, use Cmd+B/P to check delivery and rebind the command center to
|
||||
Cmd+J to check toggle and rebind behavior. Cmd+K delivery needs a physical iPad.
|
||||
Simulator also reserves Cmd+1…4, Cmd+Shift+A and Cmd+comma. Exercise those actions
|
||||
through rebinds. For numeric injection, use AppleScript physical key codes (for example
|
||||
25 for 9); `keystroke "9"` did not deliver a digit in the QA session.
|
||||
Simulator exposes a virtual `GCKeyboard` even with Connect Hardware Keyboard off.
|
||||
Verify the iOS absence case on a physical iPhone.
|
||||
|
||||
Never register Backspace: it would take deletion away from text fields. Changes
|
||||
to the native module require a native rebuild; an over-the-air JavaScript update
|
||||
cannot deliver them.
|
||||
|
||||
## Overlay ownership
|
||||
|
||||
Register keyboard handling with `useOverlayRegistration` from `@/keyboard`.
|
||||
Pass the surface's open state, layer, handled keys and `onKeyDown`. The highest
|
||||
layer gets first refusal; the latest registration wins a tie. Native registration
|
||||
does not need a DOM ref. Web adds focus trapping and restoration through its adapter.
|
||||
|
||||
Capture a global overlay’s layer in state for its opening lifetime. Memo caches can
|
||||
be discarded or removed by React Compiler; recomputing against the registry lets an
|
||||
open modal rise above its own children and creates competing focus traps.
|
||||
|
||||
Declare Enter and arrow keys only while the surface owns them. Native sources
|
||||
use the top overlay's declarations to take those keys before text controls edit.
|
||||
Backspace stays with the focused input. Composition confirmation must not select
|
||||
an overlay row or submit the composer.
|
||||
|
||||
Android modals have their own windows. Observe them through React Native's
|
||||
`ExtraWindowEventListener` as well as the activity window. The dev client bypasses
|
||||
Expo's root-container handler, so an activity root-view wrapper misses presses.
|
||||
A modal can forward an unhandled event into the activity window. Observe the event
|
||||
once across that nested delivery without retaining recycled Android `KeyEvent`s.
|
||||
Keep an overlay-owned press consumed through key-up, even if its key-down closes
|
||||
the overlay; otherwise the modal's default Escape handler can close a second layer.
|
||||
|
||||
iOS scene startup installs the observing window after Expo creates its root view.
|
||||
Keep that integration in the hardware keyboard module's config plugin. Its window
|
||||
owns both the press stream and the text-control key commands, including above modals.
|
||||
|
||||
## Focus scopes
|
||||
|
||||
The composer and terminal declare focus with `useKeyboardFocusSurface`. Native
|
||||
scope derives from those declarations and command-center state. Releasing an old
|
||||
surface must not clear a newer owner's declaration during a tab switch. Web keeps
|
||||
its DOM-derived scope.
|
||||
|
||||
Escape interrupts an agent only in the message input or an unclaimed surface.
|
||||
A focused terminal excludes that binding. The source leaves terminal Escape alone;
|
||||
terminal Escape/Ctrl forwarding is a separate feature.
|
||||
|
||||
## Keys shared with text editing
|
||||
|
||||
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
|
||||
workspace archive undo, declares `editable: false` and registers its handler with
|
||||
`isActive` true only while it has work. An inactive handler leaves the key
|
||||
unhandled, so the pipeline skips `preventDefault` and the platform's own undo runs.
|
||||
@@ -26,6 +26,8 @@ Native code blocks use the library's copy control and compiled grammars. Web cod
|
||||
|
||||
The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild.
|
||||
|
||||
On iOS a newly mounted Markdown view paints empty for a frame or more after it is laid out at its measured height: the library parses even a view's first content on a background queue and applies it in a later main-queue turn. Expanding a turn in chat therefore shows its rows in place with the text arriving a frame later (165 ms when the mount keeps the main thread busy). Android paints the text in the first frame. Rendering a view's first content synchronously fixes the frame but costs frame time when a fling mounts history rows, so the app keeps the library's behavior until [enriched-markdown #1131](https://github.com/software-mansion/enriched-markdown/issues/1131) is resolved.
|
||||
|
||||
Android measurements use a size-only, 512-entry LRU across view unmounts, matching the library’s existing iOS cache. Scrolling back through history can reuse layout sizes without parsing those rows again. Content, layout props and width identify each entry; font-scale changes and native layout invalidation discard affected sizes. The cache retains no views or spans.
|
||||
|
||||
The library determines how incomplete Markdown appears during streaming. Completed text uses the same component and engine as history. Find and scroll-to-message address source message ids; selection-copy understands semantic HTML and the web code component's copy metadata.
|
||||
|
||||
+3
-2
@@ -226,7 +226,8 @@ its own.
|
||||
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
|
||||
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
|
||||
inactive, and that inactive commit is where the surface unmounts.
|
||||
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
|
||||
card, and are unit-tested. Fix positioning bugs there, not at a call site.
|
||||
- Anchoring, flipping, edge clamping, and capping a surface's width to the display live in
|
||||
`components/ui/anchor.ts`, shared with the hover card, and are unit-tested. Clamping moves a
|
||||
surface but cannot shrink it, so the width cap is what keeps a wide menu on a narrow screen. Fix positioning bugs there, not at a call site.
|
||||
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
|
||||
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
|
||||
|
||||
+39
-10
@@ -9,6 +9,10 @@ Compact layouts have three mutually exclusive destinations:
|
||||
They are one interaction, not two independent drawers. The implementation lives in
|
||||
`packages/app/src/mobile-panels/`.
|
||||
|
||||
Wide layouts reuse `agent-list` for the navigation overlay. When the left dock is collapsed or
|
||||
yields its width, a touch swipe reveals the same sidebar over the workspace at the resolved sidebar
|
||||
width. The workspace does not move, and the saved dock preference does not change.
|
||||
|
||||
## Ownership
|
||||
|
||||
React/Zustand owns the durable intent:
|
||||
@@ -23,6 +27,12 @@ interface MobilePanelSelection {
|
||||
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
||||
Compact panel selection is not persisted; a cold start begins at `agent`.
|
||||
|
||||
Only an overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`. App
|
||||
chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`, `dock`, or
|
||||
`hidden`) to `panel-store`, which drops any target the layout does not present. A stale
|
||||
`agent-list` would reopen the overlay on the next collapse; a stale `file-explorer` after widening
|
||||
would block the navigation swipe, which begins only from `agent`.
|
||||
|
||||
The UI worklet owns transient motion:
|
||||
|
||||
- one normalized position (`-1` left, `0` center, `1` right)
|
||||
@@ -32,8 +42,10 @@ The UI worklet owns transient motion:
|
||||
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted. The Gesture Handler web patch keeps initially disabled hosts
|
||||
from capturing paragraph pointers; that capture confines browser selection to its starting paragraph.
|
||||
The gesture hosts stay mounted. A pointer capture on a paragraph confines browser selection to that
|
||||
paragraph, so the Gesture Handler web patch keeps it off: initially disabled hosts register no
|
||||
pointer listeners, and an enabled host captures only a pointer its handler still tracks after
|
||||
pointer-down. The touch-only navigation pans fail a mouse at pointer-down and never capture it.
|
||||
Native worklets own overlay opacity and pointer events while
|
||||
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
|
||||
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
|
||||
@@ -43,8 +55,11 @@ appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-them
|
||||
|
||||
## Why one position
|
||||
|
||||
Both transforms and both backdrop opacities are derived from the same normalized position. Window
|
||||
width is only a projection input. Rotation changes the projection, not the panel state.
|
||||
Both transforms and both backdrop opacities are derived from the same normalized position. Panel
|
||||
width is only a projection input: the window width on the right, and on the left the width
|
||||
`MobilePanelsProvider` resolves (the full window on compact layouts, the saved sidebar width
|
||||
clamped to the viewport on wide ones). The dock, the overlay, and their gestures read that one
|
||||
value. Rotation changes the projection, not the panel state.
|
||||
|
||||
This makes these invalid states unrepresentable:
|
||||
|
||||
@@ -62,7 +77,9 @@ A gesture captures the current revision when it becomes active. Per-frame update
|
||||
while that revision still owns the gesture.
|
||||
|
||||
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
||||
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
||||
motion toward its target, even an unchanged one: docking mid-swipe keeps `agent` and still returns
|
||||
the dragged position to its anchor. The older gesture's remaining updates and finish callback are
|
||||
ignored.
|
||||
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
|
||||
motion immediately; the matching React command adopts that motion instead of restarting it. Position
|
||||
settlement and command acceptance may arrive in either order. Activity changes after both agree on
|
||||
@@ -80,6 +97,12 @@ definition, no longer eligible to begin.
|
||||
|
||||
- Callers request semantic targets through `panel-store`; they never write shared values.
|
||||
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
|
||||
- Navigation gestures accept touch pointers only. Mouse and pen fail at touch-down, so dragging a
|
||||
mouse to select text on a hybrid laptop never moves a panel. Browsers without a touch screen
|
||||
(`hooks/pointer-capabilities`) never enable them.
|
||||
- Phone web opens a drawer only from the 32px edge it slides in from, so interior swipes stay with
|
||||
terminals, code blocks, and diff scrollers. Native drawers and the wide overlay open from anywhere.
|
||||
Horizontal-scroll ownership and the open-gesture blocker still take precedence.
|
||||
- A focused interactive surface may block panel-opening gestures through
|
||||
`useBlockMobilePanelOpenGestures`. Register only while the conflicting interaction is active and
|
||||
unregister when the surface is hidden or unfocused. The blocker never disables gestures that close
|
||||
@@ -87,11 +110,17 @@ definition, no longer eligible to begin.
|
||||
- Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation
|
||||
across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View`
|
||||
so its injected `collapsable={false}` reaches Android/Fabric.
|
||||
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
|
||||
styles in sidebar components.
|
||||
- The desktop left sidebar is retained too. App chrome owns separate mounted and visible decisions:
|
||||
closing it or yielding its width marks it inactive and applies `display: none` without conditionally
|
||||
removing the sidebar tree.
|
||||
- Sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion styles
|
||||
in sidebar components. The wide left sidebar keeps the same content and gesture hosts when switching
|
||||
between dock and overlay. App chrome owns mounting and presentation; settled panel activity owns
|
||||
overlay work. A hidden dock is inactive with `display: none`; the sidebar tree stays mounted. Keep
|
||||
the native overlay laid out while hidden, as on compact layouts. Give the switching host no
|
||||
Reanimated layout props: a width keyed on the presentation reaches Android after React commits the
|
||||
switch, and a lost update left the workspace at the dock width. The dock sizes to its content and
|
||||
the overlay host fills the window.
|
||||
- The overlay's backdrop covers the whole window; safe-area padding applies to the sidebar and
|
||||
workspace content only. It closes by swiping back, tapping the backdrop, or picking a destination:
|
||||
every sidebar destination closes the overlay before it navigates.
|
||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
|
||||
+1
-10
@@ -452,18 +452,9 @@ Regular `View` components can safely use Unistyles dynamic styles — the confli
|
||||
|
||||
## Native Chat Stream Layout
|
||||
|
||||
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
|
||||
|
||||
- chronological stream order
|
||||
- strategy-ordered array order
|
||||
- native inverted cell visual order
|
||||
|
||||
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
|
||||
|
||||
Platform-specific stream edges belong on `StreamStrategy`:
|
||||
|
||||
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
|
||||
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
|
||||
Platform-specific stream edges belong on `StreamStrategy`. Web and native both render in chronological order, so both use the last history item as the history/live-head boundary and render content before a footer.
|
||||
|
||||
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
|
||||
|
||||
|
||||
@@ -2,15 +2,6 @@
|
||||
|
||||
Keep completed assistant prose in one native text view so selection crosses paragraphs, headings and lists. The shared [Markdown renderer](markdown-renderer.md) owns that view.
|
||||
|
||||
## Android inverted chat
|
||||
|
||||
Keep selection visibility handling in `modules/paseo-scroll`. Android's visibility
|
||||
rectangles ignore list inversion. React Native decomposes Android's `scale: -1`
|
||||
into rotation with positive `scaleY`, so test the composed matrix when deciding
|
||||
whether those requests should move the viewport. Native tests must apply the
|
||||
list's transform through React Native's view manager; assigning `scaleY` directly
|
||||
misses that decomposition.
|
||||
|
||||
## Diagrams in lists and quotes
|
||||
|
||||
Selection stops at a Mermaid diagram. Native extracts Mermaid fences into separate views because the library exposes no native block renderer override. A diagram inside a list or quote therefore appears outside that container, and its following prose starts a separate Markdown fragment.
|
||||
|
||||
+9
-6
@@ -281,23 +281,26 @@ Drafts do not appear in GitHub's releases feed. Updater clients continue to see
|
||||
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
|
||||
### Instant-admit release (rollout_hours=0 from publish)
|
||||
### Instant-admit release (rollout_hours=0)
|
||||
|
||||
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
|
||||
`npm run release:patch` and `npm run release:minor` tag-push, and tag-push runs always stamp 36 hours. To admit everyone immediately (hotfix, doc-only change, a release you want out fast), cut the release normally, wait for it to publish, then flip the manifests:
|
||||
|
||||
```bash
|
||||
# 1. Cut and publish (default 36h ramp from tag push).
|
||||
# 1. Cut and publish (36h ramp from tag push).
|
||||
npm run release:patch
|
||||
|
||||
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
|
||||
# 2. Wait for Desktop Release to publish the GitHub Release.
|
||||
gh run watch <desktop-release-run-id> --exit-status
|
||||
|
||||
# 3. Flip the published manifests to instant admit.
|
||||
gh workflow run desktop-rollout.yml \
|
||||
-f tag=v0.1.64 \
|
||||
-f rollout_hours=0
|
||||
```
|
||||
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
Dispatch only after `finalize-rollout` has published the release. Until then the release is a draft, and a draft has no tag: `desktop-rollout.yml` looks the release up by tag and fails with `release not found`. The concurrency group `desktop-rollout-<tag>` that the two workflows share orders them only once both are queued; it does not hold an early dispatch until the draft is published.
|
||||
|
||||
Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish.
|
||||
The first public manifests carry `rolloutHours=36` for the minutes between publish and the flip. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
|
||||
### Adjusting an already-published release
|
||||
|
||||
|
||||
@@ -95,9 +95,10 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
|
||||
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
|
||||
|
||||
The daemon validates the epoch and locates the projected item at the selected position. A fork
|
||||
includes projected items through that checkpoint. If an item spans the checkpoint and changed
|
||||
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
|
||||
cannot be reconstructed from sequence metadata. Forking the current context remains available.
|
||||
includes projected items through that checkpoint. If an item the fork copies spans the checkpoint
|
||||
and changed afterward, the daemon refuses that fork with an actionable error: discarded historical
|
||||
payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card
|
||||
updated after the checkpoint does not block it. Forking the current context remains available.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||
|
||||
Improved
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||
|
||||
Improved
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||
|
||||
Improved
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it
|
||||
- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step
|
||||
|
||||
Improved
|
||||
- Changed the theme picker to a grid of tiles with a preview of each theme
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-iAi82L6Lm75hdg/9tJYyYcHMhCJLtXGSpcNqgv9QQaw=
|
||||
sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
|
||||
|
||||
Generated
+760
-640
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -131,6 +131,7 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "44.2.0",
|
||||
"electron-builder": "26.18.0",
|
||||
"expo-module-scripts": "^5.0.8",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -148,14 +149,21 @@
|
||||
"overrides": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@electron/get@^3.0.0": {
|
||||
"semver@^6": "6.3.1"
|
||||
},
|
||||
"expo": "~57.0.30",
|
||||
"expo-router": "~57.0.28",
|
||||
"lightningcss": "1.30.1",
|
||||
"pkijs": "3.4.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-reanimated": "4.5.5",
|
||||
"react-native-worklets": "0.10.4"
|
||||
"react-native-worklets": "0.10.4",
|
||||
"tiny-async-pool": {
|
||||
"semver@^5": "5.7.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
searchPaths: ["../../node_modules", "./node_modules"],
|
||||
},
|
||||
plugins: [
|
||||
"./modules/paseo-hardware-keyboard/app.plugin",
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
withAndroidScroll,
|
||||
|
||||
@@ -94,3 +94,21 @@ it("allows all orientations on iPhone and iPad", async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// Scene startup is the integration boundary for the iOS keyboard window.
|
||||
describe("hardware keyboard scene integration", () => {
|
||||
it("installs observation after Expo creates the scene window and is idempotent", () => {
|
||||
const {
|
||||
configureHardwareKeyboardAppDelegate,
|
||||
} = require("./modules/paseo-hardware-keyboard/app.plugin.js");
|
||||
const source =
|
||||
"import Expo\nclass Scene {\n super.scene(scene, willConnectTo: session, options: connectionOptions)\n}\n";
|
||||
const configured = configureHardwareKeyboardAppDelegate(source);
|
||||
expect(configured).toContain("import PaseoHardwareKeyboard");
|
||||
expect(configured).toContain("PaseoHardwareKeyboardWindow.observing(window)");
|
||||
expect(configured.indexOf("super.scene")).toBeLessThan(
|
||||
configured.indexOf(".observing(window)"),
|
||||
);
|
||||
expect(configureHardwareKeyboardAppDelegate(configured)).toBe(configured);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
expectNewWorkspaceForAddedProject,
|
||||
expectAddProjectResultVisible,
|
||||
navigateAddProjectResults,
|
||||
navigateAndDismissAddProject,
|
||||
watchAddProjectRenderWarnings,
|
||||
openAddProjectFlow,
|
||||
openAddProjectHostSelection,
|
||||
} from "../support/helpers/add-project-flow";
|
||||
@@ -146,6 +148,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
test("Escape and Back restore searchable page input before closing at the root", async ({
|
||||
page,
|
||||
}) => {
|
||||
const renderWarnings = watchAddProjectRenderWarnings(page);
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
@@ -183,8 +186,9 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expectAddProjectPage(page, "host");
|
||||
await page.keyboard.press("Escape");
|
||||
await navigateAndDismissAddProject(page);
|
||||
await expect(addProjectFlow(page)).not.toBeVisible();
|
||||
expect(renderWarnings).toEqual([]);
|
||||
});
|
||||
|
||||
test("New directory creates a Project on the selected remote host", async ({ page }) => {
|
||||
|
||||
@@ -235,6 +235,7 @@ async function beginTimelineRowStabilityCheck(
|
||||
lastTop: initialRow.getBoundingClientRect().top,
|
||||
largestDownwardShift: 0,
|
||||
sawMissing: false,
|
||||
sawHidden: false,
|
||||
};
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
windowState.__messageSubmissionTimelineRowStability = state;
|
||||
@@ -245,6 +246,9 @@ async function beginTimelineRowStabilityCheck(
|
||||
requestAnimationFrame(checkFrame);
|
||||
return;
|
||||
}
|
||||
if (!initialRow.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
|
||||
state.sawHidden = true;
|
||||
}
|
||||
const top = initialRow.getBoundingClientRect().top;
|
||||
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
|
||||
state.lastTop = top;
|
||||
@@ -257,14 +261,24 @@ async function beginTimelineRowStabilityCheck(
|
||||
const result = await page.evaluate(() => {
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
const state = windowState.__messageSubmissionTimelineRowStability as
|
||||
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
|
||||
| {
|
||||
active: boolean;
|
||||
largestDownwardShift: number;
|
||||
sawMissing: boolean;
|
||||
sawHidden: boolean;
|
||||
}
|
||||
| undefined;
|
||||
if (!state) throw new Error("Timeline-row stability check was not started");
|
||||
state.active = false;
|
||||
delete windowState.__messageSubmissionTimelineRowStability;
|
||||
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
|
||||
return {
|
||||
largestDownwardShift: state.largestDownwardShift,
|
||||
sawMissing: state.sawMissing,
|
||||
sawHidden: state.sawHidden,
|
||||
};
|
||||
});
|
||||
expect(result.sawMissing).toBe(false);
|
||||
expect(result.sawHidden).toBe(false);
|
||||
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
|
||||
};
|
||||
}
|
||||
@@ -1178,7 +1192,13 @@ test.describe("Agent message submission", () => {
|
||||
test.setTimeout(120_000);
|
||||
const toasts = await recordPanelToasts(page);
|
||||
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
||||
// The created agent takes over the draft's timeline: the same row stays mounted and painted.
|
||||
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
|
||||
page,
|
||||
pending.userMessage,
|
||||
);
|
||||
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
||||
await finishTimelineRowStabilityCheck();
|
||||
// A chat this client just created is current by construction; it is never out of date.
|
||||
await toasts.expectNeverShown("agent-updating-toast");
|
||||
});
|
||||
|
||||
@@ -723,3 +723,46 @@ test("copying a selection across an assistant image keeps its Markdown source an
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("on a touch-capable desktop browser", () => {
|
||||
// A touch screen registers the navigation pan's pointer listeners over the whole workspace.
|
||||
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-drag-",
|
||||
title: "Assistant selection drag",
|
||||
initialPrompt: "Render three paragraphs.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
const blocks = assistantMessageBlocks(page);
|
||||
const first = await blocks.getByText("First paragraph.").boundingBox();
|
||||
const third = await blocks.getByText("Third paragraph.").boundingBox();
|
||||
if (!first || !third) {
|
||||
throw new Error("Expected rendered paragraphs");
|
||||
}
|
||||
|
||||
await page.mouse.move(first.x + 1, first.y + first.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(third.x + third.width - 1, third.y + third.height / 2, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||
expect(selected).toContain("First paragraph.");
|
||||
expect(selected).toContain("Third paragraph.");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectWorkspaceShownIdle,
|
||||
finishAndArchiveAgent,
|
||||
startBackgroundAgent,
|
||||
} from "../support/helpers/background-agents";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectSubagentRowGone,
|
||||
expectSubagentRowVisible,
|
||||
openSubagentsTrack,
|
||||
seedParentWithSubagent,
|
||||
seedSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
import { expectOnlyWorkspaceAgentTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Background agents", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "background-agents-",
|
||||
title: "Classifier workspace",
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("a background agent runs in an open workspace without announcing itself", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Feature agent",
|
||||
childTitle: "Visible helper",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
|
||||
const classifier = await test.step("a plugin starts a background agent", () =>
|
||||
startBackgroundAgent(workspace, { parentId: agents.parent.id, title: "Classifier" }));
|
||||
// A visible child created after the classifier reaches the app on the same stream, so once
|
||||
// its row shows, the app has processed the classifier too.
|
||||
const marker = await seedSubagent(workspace, {
|
||||
parentId: agents.parent.id,
|
||||
title: "Ordering marker",
|
||||
});
|
||||
|
||||
await test.step("it stays out of the track, the tabs and the sidebar status", async () => {
|
||||
await expectSubagentRowVisible(page, marker.id);
|
||||
await expectSubagentRowGone(page, classifier.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
|
||||
await expectWorkspaceShownIdle(page, "Classifier workspace");
|
||||
});
|
||||
|
||||
await test.step("the plugin reads its finish and archives it", async () => {
|
||||
await finishAndArchiveAgent(workspace, classifier.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
|
||||
await expectSubagentRowGone(page, classifier.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -323,6 +323,110 @@ test("All changes combines committed and pending edits and preserves an unfinish
|
||||
});
|
||||
});
|
||||
|
||||
test("Changes selects exact local and remote bases and retains the selection after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorktreeWithCommittedDiff();
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "main"], {
|
||||
cwd: workspace.repoPath,
|
||||
});
|
||||
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const picker = tree.getByTestId("workspace-base-branch-trigger");
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.click();
|
||||
await expect(page.getByRole("button", { name: "release", exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("base-picker.png") });
|
||||
await page.getByRole("button", { name: "release", exact: true }).click();
|
||||
await expect(picker).toContainText("release");
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await picker.click();
|
||||
await page.getByRole("button", { name: "upstream/release", exact: true }).click();
|
||||
await expect(picker).toContainText("upstream/release");
|
||||
await expect(tree.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("base-selected.png") });
|
||||
await page.reload();
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(picker).toContainText("upstream/release");
|
||||
expect(execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString()).toBe(
|
||||
head,
|
||||
);
|
||||
});
|
||||
|
||||
test("Changes lets a compact workspace change its base in a sheet", async ({ page }, testInfo) => {
|
||||
const workspace = await createWorktreeWithCommittedDiff();
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
await openCompactChanges(page, workspace);
|
||||
const picker = page.getByRole("button", { name: "Change base" }).filter({ visible: true });
|
||||
await picker.click();
|
||||
await expect(page.getByText("Change base", { exact: true })).toBeVisible();
|
||||
const release = page.getByRole("button", { name: "release", exact: true });
|
||||
await expect(release).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("compact-base-picker.png") });
|
||||
await release.click();
|
||||
await expect(picker).toContainText("release");
|
||||
await page.screenshot({ path: testInfo.outputPath("compact-base-selected.png") });
|
||||
});
|
||||
|
||||
test("Changes shows a failed base selection and lets the user retry", async ({ page }) => {
|
||||
const workspace = await createWorktreeWithCommittedDiff();
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
const picker = page
|
||||
.getByTestId("workspace-base-branch-trigger")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await picker.click();
|
||||
const option = page.getByRole("button", { name: "release", exact: true });
|
||||
await expect(option).toBeVisible();
|
||||
execFileSync("git", ["branch", "-D", "release"], { cwd: workspace.repoPath });
|
||||
await option.click();
|
||||
await expect(page.getByTestId("workspace-base-branch-error")).toContainText(
|
||||
"Base branch not found",
|
||||
);
|
||||
await expect(picker).toContainText("main");
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
await option.click();
|
||||
await expect(page.getByTestId("workspace-base-branch-error")).toHaveCount(0);
|
||||
await expect(picker).toContainText("release");
|
||||
});
|
||||
|
||||
test("Changes shows the base as text on a host without the base-branch capability", async ({
|
||||
page,
|
||||
}) => {
|
||||
let baseRequests = 0;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (typeof frame === "string") {
|
||||
const envelope = JSON.parse(frame) as { message?: { type?: string } };
|
||||
if (envelope.message?.type === "workspace.base_branch.set.request") baseRequests += 1;
|
||||
}
|
||||
server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => {
|
||||
if (typeof frame === "string") {
|
||||
const envelope = JSON.parse(frame) as {
|
||||
message?: { payload?: { status?: string; features?: Record<string, unknown> } };
|
||||
};
|
||||
const payload = envelope.message?.payload;
|
||||
if (payload?.status === "server_info" && payload.features) {
|
||||
delete payload.features.workspaceBaseBranch;
|
||||
browser.send(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
const workspace = await createWorktreeWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
|
||||
expect(baseRequests).toBe(0);
|
||||
});
|
||||
|
||||
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
@@ -928,11 +1032,11 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
throw new Error("Compact Changes toolbar geometry could not be measured");
|
||||
}
|
||||
expect(actionsBox.width).toBe(48);
|
||||
expect(actionsBox.height).toBe(28);
|
||||
expect(optionsBox.width).toBe(32);
|
||||
expect(optionsBox.height).toBe(32);
|
||||
expect(glyphBox.width).toBe(18);
|
||||
expect(glyphBox.height).toBe(18);
|
||||
expect(actionsBox.height).toBe(32);
|
||||
expect(optionsBox.width).toBe(44);
|
||||
expect(optionsBox.height).toBe(44);
|
||||
expect(glyphBox.width).toBe(20);
|
||||
expect(glyphBox.height).toBe(20);
|
||||
|
||||
await expect(actions).not.toContainText("Commit");
|
||||
await expect(actions.locator("svg")).toHaveCount(2);
|
||||
@@ -1657,6 +1761,28 @@ async function expectRenderedAddedLine(page: Page, contents: string): Promise<vo
|
||||
}).toPass({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function createWorktreeWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-base-worktree-", {
|
||||
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
},
|
||||
});
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "worktree", cwd: repo.path, worktreeSlug: "base-picker", baseBranch: "main" },
|
||||
});
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create worktree");
|
||||
const cwd = created.workspace.workspaceDirectory;
|
||||
await writeFile(path.join(cwd, "committed-only.ts"), "export const committed = true;\n");
|
||||
execFileSync("git", ["add", "committed-only.ts"], { cwd });
|
||||
execFileSync("git", ["commit", "-m", "Add committed-only file"], { cwd });
|
||||
return { id: created.workspace.id, repoPath: cwd };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-committed-", {
|
||||
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
@@ -59,6 +60,14 @@ async function openCompactActionsMenu(page: Page, workspaceId: string) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function expectActionsInOrder(menu: Locator, labels: readonly string[]) {
|
||||
const items = menu.getByRole("menuitem");
|
||||
await expect(items).toHaveCount(labels.length);
|
||||
for (const [index, label] of labels.entries()) {
|
||||
await expect(items.nth(index).getByText(label, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
|
||||
const repoPath = await createRemoteRepo("compact-menu-pull-");
|
||||
cleanupTasks.push(async () => {
|
||||
@@ -83,12 +92,7 @@ test("compact git actions menu lists a promoted Pull once", async ({ page }) =>
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Pull",
|
||||
"Push",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
await expectActionsInOrder(menu, ["Pull", "Push", "Pull and push", "Archive workspace"]);
|
||||
});
|
||||
|
||||
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
|
||||
@@ -106,10 +110,5 @@ test("compact git actions menu lists a promoted Push once", async ({ page }) =>
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Push",
|
||||
"Pull",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
await expectActionsInOrder(menu, ["Push", "Pull", "Pull and push", "Archive workspace"]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { pauseWorkspaceTerminalWorker } from "../support/helpers/workspace-setup";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
@@ -190,3 +191,49 @@ test("compact header menu runs, shows, and stops workspace scripts", async ({ pa
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function restartScriptAcrossHeaderRemoval(page: Page, terminalId: string) {
|
||||
await openRunningScriptPage(page, "lint");
|
||||
const resumeWorker = await pauseWorkspaceTerminalWorker(terminalId);
|
||||
try {
|
||||
await menuRow(page, "Restart").click();
|
||||
await page.setViewportSize({ width: 1280, height: 844 });
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveCount(0);
|
||||
await expect(page.getByTestId("workspace-scripts-button")).toBeVisible();
|
||||
} finally {
|
||||
resumeWorker();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectScriptTerminalReplaced(
|
||||
workspace: Awaited<ReturnType<typeof openWorkspaceWithScripts>>,
|
||||
previousId: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const after = await workspace.client.listTerminals(undefined, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
return after.terminals.filter((terminal) => terminal.id !== previousId).length;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(1);
|
||||
}
|
||||
|
||||
test("pending script restart survives removal of the compact header", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const workspace = await openWorkspaceWithScripts(page);
|
||||
try {
|
||||
await runScriptFromScriptsPage(page, "lint");
|
||||
const before = await workspace.client.listTerminals(undefined, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
expect(before.terminals).toHaveLength(1);
|
||||
await restartScriptAcrossHeaderRemoval(page, before.terminals[0]!.id);
|
||||
await expectScriptTerminalReplaced(workspace, before.terminals[0]!.id);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
async function box(locator: Locator) {
|
||||
await expect(locator).toBeVisible({ timeout: 30_000 });
|
||||
const rect = await locator.boundingBox();
|
||||
expect(rect).not.toBeNull();
|
||||
return { top: rect!.y, bottom: rect!.y + rect!.height };
|
||||
}
|
||||
|
||||
test.describe("Composer bottom spacing", () => {
|
||||
test("the chat composer rests one content spacing above the window edge", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-bottom-spacing-",
|
||||
title: "Bottom spacing",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
const surface = await box(
|
||||
page.getByTestId("message-input-root").filter({ visible: true }).first(),
|
||||
);
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight);
|
||||
expect(viewportHeight - surface.bottom).toBe(16);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the wide new workspace form keeps one content spacing above Import session", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-bottom-spacing-form-" });
|
||||
try {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
const composer = await box(page.locator('[data-testid="message-input-root"]:visible'));
|
||||
const importButton = await box(page.getByTestId("new-workspace-import-session"));
|
||||
expect(importButton.top - composer.bottom).toBe(16);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,17 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { fillComposerDraft } from "../support/helpers/composer";
|
||||
import {
|
||||
expectComposerToolbarOnTextRails,
|
||||
expectNoCollapsedComposerToolbarFrame,
|
||||
expectNoFrameTruncatesModelBesideThinking,
|
||||
readSettledModelTriggerLabels,
|
||||
recordComposerToolbarFrames,
|
||||
recordModelTriggerFramesFromLoad,
|
||||
sweepModelTriggerLabels,
|
||||
} from "../support/helpers/composer-control-density";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const SETTLE_MS = 1_000;
|
||||
@@ -92,3 +99,159 @@ test.describe("Composer control density across tab switches", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Composer controls folding on a touch device", () => {
|
||||
test.use({ viewport: { width: 1194, height: 834 }, isMobile: true, hasTouch: true });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("an open menu stays closed after the controls fold and unfold", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-density-fold-",
|
||||
title: "Folding chat",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
const thinking = page.getByTestId("agent-thinking-selector").filter({ visible: true });
|
||||
const thinkingMenu = page.getByTestId("combobox-desktop-container");
|
||||
|
||||
await thinking.click();
|
||||
await expect(thinkingMenu).toBeVisible();
|
||||
|
||||
// Still a wide layout, but too narrow for separate controls: they fold into the model control.
|
||||
await page.setViewportSize({ width: 640, height: 834 });
|
||||
await expect(thinking).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 1194, height: 834 });
|
||||
await expect(thinking).toBeVisible();
|
||||
await expect(thinkingMenu).toHaveCount(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Folded model control on a phone", () => {
|
||||
// Wide enough for "Ten second stream" and "Low" side by side; narrower, they no longer fit.
|
||||
test.use({ viewport: { width: 480, height: 844 }, isMobile: true, hasTouch: true });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("hides the thinking label before the model label truncates", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-density-labels-",
|
||||
title: "Label priority chat",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await recordModelTriggerFramesFromLoad(page);
|
||||
await openAgentRoute(page, session);
|
||||
await expect(
|
||||
page.getByTestId("combined-model-selector-thinking-label").filter({ visible: true }),
|
||||
).toHaveText("Low");
|
||||
await readSettledModelTriggerLabels(page);
|
||||
// From the first frame on, the control never guesses: it paints both labels only once it
|
||||
// has measured that they fit.
|
||||
await expectNoFrameTruncatesModelBesideThinking(page);
|
||||
|
||||
const sweep = await sweepModelTriggerLabels(page, {
|
||||
from: 480,
|
||||
to: 280,
|
||||
step: 4,
|
||||
height: 844,
|
||||
});
|
||||
|
||||
expect(sweep.widest).toMatchObject({
|
||||
modelText: "Ten second stream",
|
||||
thinkingShown: true,
|
||||
modelTruncated: false,
|
||||
});
|
||||
expect(sweep.thinkingHidden).toMatchObject({
|
||||
modelText: "Ten second stream",
|
||||
modelTruncated: false,
|
||||
});
|
||||
expect(sweep.modelTruncated.width).toBeLessThan(sweep.thinkingHidden.width);
|
||||
// A clean tail: the label ends on a whole word and the ellipsis, never on a space.
|
||||
expect(sweep.modelTruncated).toMatchObject({
|
||||
modelText: "Ten second…",
|
||||
thinkingShown: false,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Composer toolbar rails on touch", () => {
|
||||
test.use({ isMobile: true, hasTouch: true });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
for (const viewport of [
|
||||
{ device: "phone", width: 390, height: 844 },
|
||||
{ device: "tablet", width: 1024, height: 1366 },
|
||||
]) {
|
||||
test(`the toolbar starts on the text rail and ends in a disc nested in the corner on a ${viewport.device}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
const idle = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-rails-idle-",
|
||||
title: "Rails idle chat",
|
||||
});
|
||||
let running: Awaited<ReturnType<typeof seedMockAgentWorkspace>> | null = null;
|
||||
try {
|
||||
await openAgentRoute(page, idle);
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await test.step("empty composer: attach on the left, voice mode on the right", async () => {
|
||||
await expectComposerToolbarOnTextRails(page);
|
||||
});
|
||||
await test.step("draft: the send disc on the right", async () => {
|
||||
await fillComposerDraft(page, "Refactor the composer toolbar");
|
||||
await expect(page.getByRole("button", { name: /send/i }).first()).toBeVisible();
|
||||
await expectComposerToolbarOnTextRails(page);
|
||||
});
|
||||
|
||||
running = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-rails-running-",
|
||||
title: "Rails running chat",
|
||||
model: "one-minute-stream",
|
||||
initialPrompt: "stream for a while",
|
||||
});
|
||||
await openAgentRoute(page, running);
|
||||
await test.step("running agent: the stop disc on the right", async () => {
|
||||
await expect(page.getByRole("button", { name: /stop agent/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerToolbarOnTextRails(page);
|
||||
});
|
||||
} finally {
|
||||
await idle.cleanup();
|
||||
await running?.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Composer trailing disc on a new chat", () => {
|
||||
test.use({ isMobile: true, hasTouch: true, viewport: { width: 1024, height: 1366 } });
|
||||
|
||||
test("an empty draft holds a disabled send disc until there is something to send", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-placeholder-send-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
const send = page
|
||||
.getByTestId("message-input-root")
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Send message", exact: true });
|
||||
await expect(send).toBeDisabled({ timeout: 30_000 });
|
||||
await fillComposerDraft(page, "Refactor the composer toolbar");
|
||||
await expect(send).toBeEnabled();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,7 +78,8 @@ test("composer diff stat reveals Changes, then opens the diff in the configured
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
// Keep both workspace rails visible alongside app navigation and Explorer.
|
||||
await page.setViewportSize({ width: 1900, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectMobileExplorerHidden,
|
||||
expectMobileExplorerVisible,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
openMobileAgentSidebarWithKeyboard,
|
||||
swipeOpenMobileExplorer,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
|
||||
@@ -249,7 +249,7 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
const COMPACT_PANELS = [
|
||||
{
|
||||
name: "agent list",
|
||||
open: swipeOpenMobileAgentSidebar,
|
||||
open: openMobileAgentSidebarWithKeyboard,
|
||||
expectOpen: expectMobileAgentSidebarVisible,
|
||||
close: closeMobileAgentSidebar,
|
||||
expectClosed: expectMobileAgentSidebarHidden,
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
openMobileAgentSidebarWithKeyboard,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function composerHeight(page: Page): Promise<number> {
|
||||
@@ -541,8 +541,8 @@ test("the compact sidebar over a fullscreen editor leaves the exit action alone"
|
||||
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);
|
||||
// Fullscreen hides the header's menu button; the keyboard shortcut opens the list over it.
|
||||
await openMobileAgentSidebarWithKeyboard(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
// The list covers the pane, so the editor's exit action is not armed.
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -429,6 +429,32 @@ test("context details support keyboard entry and dismiss when the viewport moves
|
||||
await expect(details).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("context details stay open while the chat streams", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-streaming-",
|
||||
title: "Context details while streaming",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await submitMessageWithButton(page, "Stream while the context details are open.");
|
||||
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||
await expect(stop).toBeVisible();
|
||||
const details = await pressContextWindowMeter(page, /^Context window/);
|
||||
await expect(stop).toBeVisible();
|
||||
await qaScreenshot(page, "context-details-while-streaming");
|
||||
|
||||
await expect(stop).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(details).toBeVisible();
|
||||
await qaScreenshot(page, "context-details-after-streaming");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import {
|
||||
@@ -9,6 +11,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Direct terminal shortcut pane placement", () => {
|
||||
test.use({ viewport: { width: 1600, height: 900 } });
|
||||
test("opens a terminal in the focused pane", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "direct-terminal-shortcut-pane-" });
|
||||
|
||||
@@ -47,3 +50,88 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
const draftPane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId("message-input-root") });
|
||||
const emptyPane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
|
||||
const draftPaneId = await draftPane.getAttribute("data-testid");
|
||||
const emptyPaneId = await emptyPane.getAttribute("data-testid");
|
||||
expect(draftPaneId).not.toBeNull();
|
||||
expect(emptyPaneId).not.toBeNull();
|
||||
await page.setViewportSize({ width: 760, height: 900 });
|
||||
return {
|
||||
originalPane: page.getByTestId(draftPaneId!),
|
||||
otherPane: page.getByTestId(emptyPaneId!),
|
||||
};
|
||||
}
|
||||
|
||||
async function launchTerminalFromCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-tab-switcher-trigger").click();
|
||||
await page.getByTestId("workspace-tab-switcher-new-terminal").click();
|
||||
await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function maximizeAndRestoreCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await expect(page.getByRole("menuitem", { name: "Split pane right", exact: true })).toBeVisible();
|
||||
await page.getByRole("menuitem", { name: "Maximize pane", exact: true }).click();
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await page.getByRole("menuitem", { name: "Restore pane", exact: true }).click();
|
||||
}
|
||||
|
||||
test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" });
|
||||
try {
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
await launchTerminalFromCompactPane(page, originalPane);
|
||||
await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await maximizeAndRestoreCompactPane(page, originalPane);
|
||||
await expect(otherPane).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectPaneHasWindowDragSurface(pane: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
pane.evaluate((element) =>
|
||||
Array.from(element.querySelectorAll<HTMLElement>("div")).some(
|
||||
(node) => node.style.getPropertyValue("-webkit-app-region") === "drag",
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" });
|
||||
try {
|
||||
await installLocalDesktopHost(page, workspace.repoPath);
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar");
|
||||
await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expectPaneHasWindowDragSurface(originalPane);
|
||||
await expectPaneHasWindowDragSurface(otherPane);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,9 +56,9 @@ test.describe("New tab keyboard launcher", () => {
|
||||
await expectLauncherSelection(launcher, "Diff");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ function collectConsoleErrors(page: Page): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
// These drag and tab-menu journeys need full rails beside the docked Explorer.
|
||||
test.use({ viewport: { width: 1900, height: 900 } });
|
||||
|
||||
test.describe("explorer pane tab placement", () => {
|
||||
test("agents open in the main pane and New-tab splits keep the app usable", async ({
|
||||
page,
|
||||
|
||||
@@ -435,7 +435,8 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
// Keep both tab rails available alongside the docked Explorer.
|
||||
await page.setViewportSize({ width: 1900, height: 900 });
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
await openWorkspaceFile(page, "target.ts");
|
||||
|
||||
@@ -12,11 +12,15 @@ import {
|
||||
openAppAsNewUser,
|
||||
openRelayPairingFromSetup,
|
||||
closeRelayPairing,
|
||||
emptyHome,
|
||||
loadingScreen,
|
||||
openTailscaleDocsFromSetup,
|
||||
paseoToolsSwitch,
|
||||
pickTheme,
|
||||
projectSuggestion,
|
||||
projectSuggestionRow,
|
||||
readFirstLaunch,
|
||||
recordFirstLaunch,
|
||||
setupModal,
|
||||
setupPrimaryButton,
|
||||
showSetupAgainFromSettings,
|
||||
@@ -99,6 +103,139 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a new user goes from the loading screen straight into setup", async ({ page }) => {
|
||||
const host = await startSetupHost("first-launch");
|
||||
|
||||
try {
|
||||
await recordFirstLaunch(page);
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
|
||||
const launch = await readFirstLaunch(page);
|
||||
expect(launch.appBeforeSetup).toBe(false);
|
||||
expect(launch.setupOpacityOnArrival).toBeLessThan(1);
|
||||
await expect(setupModal(page)).toHaveCSS("opacity", "1");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("with reduced motion, setup appears without fading in", async ({ page }) => {
|
||||
const host = await startSetupHost("reduced-motion");
|
||||
|
||||
try {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await recordFirstLaunch(page);
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
|
||||
expect((await readFirstLaunch(page)).setupOpacityOnArrival).toBe(1);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("changing the theme keeps the setup window in place on every step", async ({ page }) => {
|
||||
const host = await startSetupHost("theme-change");
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
const setupWindow = await setupModal(page).elementHandle();
|
||||
|
||||
for (const title of [
|
||||
"Welcome to Paseo",
|
||||
"Your agents",
|
||||
"Add your projects",
|
||||
"Let agents use Paseo",
|
||||
"Take your agents with you",
|
||||
]) {
|
||||
if (title !== "Welcome to Paseo") await continueSetup(page);
|
||||
await expectSetupStep(page, title);
|
||||
const heading = setupModal(page).getByRole("heading", { name: title });
|
||||
const headingNode = await heading.elementHandle();
|
||||
const lightColor = await heading.evaluate((node) => getComputedStyle(node).color);
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await expect(heading).not.toHaveCSS("color", lightColor);
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await expect(heading).toHaveCSS("color", lightColor);
|
||||
expect(await setupWindow?.evaluate((node) => node.isConnected), title).toBe(true);
|
||||
expect(await headingNode?.evaluate((node) => node.isConnected), title).toBe(true);
|
||||
}
|
||||
|
||||
await goToSetupStep(page, "Appearance");
|
||||
await pickTheme(page, "Midnight");
|
||||
await pickTheme(page, "Light");
|
||||
expect(await setupWindow?.evaluate((node) => node.isConnected)).toBe(true);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the loading screen stays up while the setup host loads its workspaces", async ({ page }) => {
|
||||
const host = await startSetupHost("slow-workspaces");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
|
||||
try {
|
||||
await recordFirstLaunch(page);
|
||||
gate.holdNextClientRequest("fetch_workspaces_request");
|
||||
await openAppAsNewUser(page, host);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(loadingScreen(page)).toBeVisible();
|
||||
await expect(emptyHome(page)).toHaveCount(0);
|
||||
|
||||
gate.releaseHeldClientRequest();
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
expect((await readFirstLaunch(page)).appBeforeSetup).toBe(false);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a setup host that never loads its workspaces still lands on the home", async ({ page }) => {
|
||||
const host = await startSetupHost("stuck-workspaces");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
|
||||
try {
|
||||
gate.holdNextClientRequest("fetch_workspaces_request");
|
||||
await openAppAsNewUser(page, host);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
|
||||
// Setup still opens once the host answers.
|
||||
gate.releaseHeldClientRequest();
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a setup host that drops while loading still lands on the home", async ({ page }) => {
|
||||
const host = await startSetupHost("dropped-workspaces");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
|
||||
try {
|
||||
gate.holdNextClientRequest("fetch_workspaces_request");
|
||||
await openAppAsNewUser(page, host);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await gate.drop();
|
||||
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an unreachable setup host lands on the home without setup", async ({ page }) => {
|
||||
const host = await startSetupHost("unreachable");
|
||||
await host.close();
|
||||
|
||||
await openAppAsNewUser(page, host);
|
||||
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => {
|
||||
const repo = await createTempGitRepo("onboarding-history-");
|
||||
const repoName = path.basename(repo.path);
|
||||
|
||||
@@ -10,6 +10,7 @@ test("plugin header and composer buttons share actions, menus, content, and upda
|
||||
await buttons.verifyObservationLifetime();
|
||||
await buttons.openWideMenusAndPopovers();
|
||||
await buttons.runAndUpdateActions();
|
||||
await buttons.keepTrackSpaceWithoutPills();
|
||||
await buttons.openCompactSheets();
|
||||
await buttons.useCompactWorkspaceMenu();
|
||||
await buttons.hideAndUnloadOpenButtons();
|
||||
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
returnAndReopenSettings,
|
||||
returnToPluginsPage,
|
||||
expectSettingsUnavailable,
|
||||
openPluginsPage,
|
||||
expectActionsMenuWithoutSettings,
|
||||
expectNoSettingsCogWithoutScreens,
|
||||
openTabbedSettings,
|
||||
selectSettingsTab,
|
||||
openTabbedSettingsLink,
|
||||
expectSettingsTooltip,
|
||||
expectNamedPluginRow,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
test("display preferences survive a reload", async ({ page }) => {
|
||||
@@ -49,6 +57,32 @@ test("Back to plugins returns from a plugin settings screen", async ({ page }) =
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
|
||||
test("plugin settings open from the cog and show a tab per screen", async ({ page }) => {
|
||||
await openPluginsPage(page);
|
||||
await test.step("plugin rows show the display name", async () => {
|
||||
await expectNamedPluginRow(page);
|
||||
});
|
||||
await test.step("the cog explains itself", async () => {
|
||||
await expectSettingsTooltip(page);
|
||||
});
|
||||
await test.step("the actions menu keeps only plugin actions", async () => {
|
||||
await expectActionsMenuWithoutSettings(page);
|
||||
});
|
||||
await test.step("tabs switch between the plugin's settings screens", async () => {
|
||||
await openTabbedSettings(page);
|
||||
await selectSettingsTab(page, "Advanced");
|
||||
await selectSettingsTab(page, "General");
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
await test.step("a plugin without settings screens shows no cog", async () => {
|
||||
await expectNoSettingsCogWithoutScreens(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("a settings screen link opens its tab", async ({ page }) => {
|
||||
await openTabbedSettingsLink(page, "Advanced");
|
||||
});
|
||||
|
||||
test.describe("compact plugin settings", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
BOTS_PLUGIN_ID,
|
||||
COMPACT,
|
||||
@@ -176,6 +177,37 @@ test.describe("Plugin sidebar items", () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("a short compact sidebar keeps workspaces usable while navigation scrolls", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 600 });
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
await expect(botRow(page, "status")).toBeAttached();
|
||||
const workspace = visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(workspace).toBeInViewport({ ratio: 1 });
|
||||
|
||||
await test.step("scrolling navigation reaches its last row without moving workspaces", async () => {
|
||||
const before = await boxOf(workspace);
|
||||
await visibleTestId(page, "sidebar-global-new-workspace").hover();
|
||||
await page.mouse.wheel(0, 800);
|
||||
await expect(botRow(page, "status")).toBeInViewport({ ratio: 1 });
|
||||
await expect(workspace).toBeInViewport({ ratio: 1 });
|
||||
expect((await boxOf(workspace)).y).toBe(before.y);
|
||||
await botRow(page, "status").click();
|
||||
await expect(page.getByText("Bot status details", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
|
||||
});
|
||||
|
||||
await test.step("the workspace still opens and closes the sidebar", async () => {
|
||||
await workspace.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(workspace).not.toBeInViewport();
|
||||
});
|
||||
});
|
||||
|
||||
test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
@@ -285,7 +317,10 @@ test.describe("Plugin sidebar items", () => {
|
||||
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
|
||||
await expect(compactSync).toBeInViewport();
|
||||
await expectFooterRow(page, compactSync);
|
||||
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
|
||||
// The compact nav block is capped at half the sidebar and scrolls past it.
|
||||
const compactDeploys = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||
await compactDeploys.scrollIntoViewIfNeeded();
|
||||
await expect(compactDeploys).toBeInViewport();
|
||||
await qaScreenshot(page, "phase2-compact-sidebar");
|
||||
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
|
||||
await compactSync.click();
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { addProjectFlow } from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
chooseFooterOverflowAction,
|
||||
expectFooterLine,
|
||||
expectFooterMenusInsideViewport,
|
||||
expectFooterNeverClipped,
|
||||
expectFooterOverflowActions,
|
||||
footerOverflowMenu,
|
||||
openFooterOverflow,
|
||||
seedDockedSidebarWidth,
|
||||
watchFooterClipping,
|
||||
} from "../support/helpers/sidebar-footer-overflow";
|
||||
import { addOfflineHostsAndReload } from "../support/helpers/hosts";
|
||||
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const TOUCH_CONTROL = 44;
|
||||
const MIN_SIDEBAR_WIDTH = 200;
|
||||
const ALL_INLINE = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-usage-icon",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
];
|
||||
// Five 44pt buttons and the row's padding need 236pt; 200pt holds three and the caret.
|
||||
const NARROW_TOUCH_LINE = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-footer-overflow",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
test.describe("Sidebar footer on a touch screen", () => {
|
||||
test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true });
|
||||
|
||||
test("a narrow docked sidebar moves the actions it can't fit behind a caret", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedDockedSidebarWidth(page, MIN_SIDEBAR_WIDTH);
|
||||
await watchFooterClipping(page);
|
||||
await gotoAppShell(page);
|
||||
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
|
||||
await expectFooterNeverClipped(page);
|
||||
|
||||
await test.step("Help opens as a page of the overflow menu", async () => {
|
||||
await openFooterOverflow(page);
|
||||
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
|
||||
await chooseFooterOverflowAction(page, "Help and support");
|
||||
await expect(page.getByRole("menuitem", { name: "What's new" })).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(footerOverflowMenu(page)).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Usage opens from the overflow menu", async () => {
|
||||
await openFooterOverflow(page);
|
||||
await chooseFooterOverflowAction(page, "Usage");
|
||||
await expect(footerOverflowMenu(page)).toBeHidden();
|
||||
await expect(page.getByTestId("usage-modal")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("usage-modal")).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Add project and Settings shortcuts still work", async () => {
|
||||
await page.keyboard.press("Control+o");
|
||||
await expect(addProjectFlow(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProjectFlow(page)).toBeHidden();
|
||||
await page.keyboard.press("Control+,");
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
});
|
||||
});
|
||||
|
||||
test("the default sidebar width keeps all five actions inline", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expectFooterLine(page, { testIDs: ALL_INLINE, controlSize: TOUCH_CONTROL });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar footer in a compact overlay narrower than the footer", () => {
|
||||
test.use({ viewport: { width: 220, height: 800 }, hasTouch: true });
|
||||
|
||||
test("the overflow menu reaches the hidden actions", async ({ page }) => {
|
||||
await watchFooterClipping(page);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
|
||||
await expectFooterNeverClipped(page);
|
||||
await openFooterOverflow(page);
|
||||
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
|
||||
await expectFooterMenusInsideViewport(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar footer on a short, narrow phone", () => {
|
||||
// 164pt for buttons holds Settings, Add project and the caret, so Hosts moves behind it.
|
||||
test.use({ viewport: { width: 180, height: 360 }, hasTouch: true });
|
||||
|
||||
test("the overflow Hosts page scrolls to its last host and Add host", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostsAndReload(
|
||||
page,
|
||||
Array.from({ length: 10 }, (_, index) => ({
|
||||
serverId: `overflow-host-${index}`,
|
||||
label: `Overflow host ${index}`,
|
||||
})),
|
||||
);
|
||||
await openCompactSidebar(page);
|
||||
await expectFooterLine(page, {
|
||||
testIDs: ["sidebar-add-project", "sidebar-footer-overflow", "sidebar-settings"],
|
||||
controlSize: TOUCH_CONTROL,
|
||||
});
|
||||
await openFooterOverflow(page);
|
||||
await expectFooterOverflowActions(page, ["Usage", "Hosts", "Help and support"]);
|
||||
await expectFooterMenusInsideViewport(page);
|
||||
await chooseFooterOverflowAction(page, "Hosts");
|
||||
await expectFooterMenusInsideViewport(page);
|
||||
const addHost = page.getByRole("menuitem", { name: "Add host" });
|
||||
await addHost.scrollIntoViewIfNeeded();
|
||||
await expect(page.getByRole("menuitem", { name: "Overflow host 9" })).toBeVisible();
|
||||
await expect(addHost).toBeInViewport();
|
||||
await addHost.click();
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,7 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await leaveSettings(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openCompactSidebar(page);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await expectFooterSeparator(page, true);
|
||||
await expectFooterIconRow(page);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
movePointerAwayFromWorkspaceHoverCard,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
scrollWorkspaceList,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -582,6 +583,12 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await page.getByTestId("menu-button").click();
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop);
|
||||
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await focusWorkspaceRowWithKeyboard(page, lastWorkspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await scrollWorkspaceList(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -645,6 +652,9 @@ test.describe("Half-screen desktop layout", () => {
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
|
||||
await explorerToggle.click();
|
||||
|
||||
@@ -38,8 +38,12 @@ async function openParentInRightPane(page: Page, parentId: string, workspaceId:
|
||||
await expect(left).toBeVisible();
|
||||
await expect(right).toBeVisible();
|
||||
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
// A later split can replace the parent pane's rail with compact chrome.
|
||||
// Keep the pane identity instead of finding it again through that rail.
|
||||
const rightPaneId = await right.getAttribute("data-testid");
|
||||
expect(rightPaneId).not.toBeNull();
|
||||
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
|
||||
return { left, right };
|
||||
return { left, right: page.getByTestId(rightPaneId!) };
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
@@ -413,6 +414,8 @@ test("searches retained output without sending Find input to the shell", async (
|
||||
test("keeps terminal Find owned by retained tabs and the focused split pane", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
// This journey selects tabs from the full rails in both split panes.
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const first = await harness.createTerminal({
|
||||
name: "First search",
|
||||
command: "bash",
|
||||
@@ -487,3 +490,142 @@ test("keeps the shared Find controls usable in a compact dark browser", async ({
|
||||
await page.getByRole("button", { name: "Close Find" }).click();
|
||||
await expect(input(page)).toBeFocused();
|
||||
});
|
||||
|
||||
// A phone browser installs xterm's document-level touch gesture listener too.
|
||||
// Keep this journey touch-enabled so it catches duplicate scroll handling.
|
||||
test.describe("terminal history with a finger", () => {
|
||||
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("scrolls history once per finger drag", async ({ page }) => {
|
||||
await openPhoneTerminalHistory(page);
|
||||
await expectSingleFingerScroll(page, 24);
|
||||
await expectSingleFingerScroll(page, 1);
|
||||
await expectMouseWheelScrollsHistory(page);
|
||||
});
|
||||
|
||||
for (const mode of ["alternate", "mouse"] as const) {
|
||||
test(`preserves finger navigation in ${mode} input mode`, async ({ page }) => {
|
||||
await openPhoneInputCapture(page, mode);
|
||||
await expectFingerNavigationInput(page, mode);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function expectFingerNavigationInput(page: Page, mode: "alternate" | "mouse") {
|
||||
await dragTerminalWithFinger(page, 24, async () => {
|
||||
const prefix = mode === "alternate" ? "TOUCH_BYTES:1b4f41" : "TOUCH_BYTES:1b5b3c3634";
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain(prefix);
|
||||
});
|
||||
}
|
||||
|
||||
async function openPhoneTerminalHistory(page: Page) {
|
||||
await harness.createTerminal({
|
||||
name: "Finger scroll history",
|
||||
command: "bash",
|
||||
args: [
|
||||
"--noprofile",
|
||||
"--norc",
|
||||
"-c",
|
||||
"for i in $(seq 1 150); do echo HISTORY_$i; done; exec sleep 300",
|
||||
],
|
||||
});
|
||||
await openPhoneTerminal(page);
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("HISTORY_150");
|
||||
}
|
||||
|
||||
async function openPhoneInputCapture(page: Page, mode: "alternate" | "mouse") {
|
||||
const escapes = mode === "alternate" ? "\x1b[?1049h\x1b[?1h" : "\x1b[?1000h\x1b[?1006h";
|
||||
await harness.createTerminal({
|
||||
name: "Touch application input",
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.once("data", () => {
|
||||
process.stdin.on("data", chunk => {
|
||||
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
|
||||
});
|
||||
process.stdout.write(${JSON.stringify(escapes)} + "TOUCH_INPUT_READY\\r\\n");
|
||||
});
|
||||
process.stdout.write("TOUCH_START_READY\\r\\n");
|
||||
`,
|
||||
],
|
||||
});
|
||||
await openPhoneTerminal(page);
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_START_READY");
|
||||
await input(page).press("x");
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__paseoTerminal!.buffer.active.type))
|
||||
.toBe(mode === "alternate" ? "alternate" : "normal");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
|
||||
.toBe(mode === "mouse" ? "vt200" : "none");
|
||||
}
|
||||
|
||||
async function openPhoneTerminal(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${harness.workspaceId}`).click();
|
||||
await expect(page.getByRole("textbox", { name: "Terminal input" })).toBeVisible();
|
||||
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectMouseWheelScrollsHistory(page: Page) {
|
||||
const beforeWheel = (await viewport(page))!;
|
||||
await page.mouse.move(200, 300);
|
||||
await page.mouse.wheel(0, -120);
|
||||
await expect.poll(() => viewport(page)).toBeLessThan(beforeWheel);
|
||||
}
|
||||
|
||||
async function expectSingleFingerScroll(page: Page, moves: number) {
|
||||
await dragTerminalWithFinger(page, moves, async ({ lineHeight, before }) => {
|
||||
await expect.poll(async () => before - (await viewport(page))!).toBeGreaterThan(0);
|
||||
const moved = before - (await viewport(page))!;
|
||||
await test.info().attach("finger-scroll", {
|
||||
body: JSON.stringify({ moves, distancePx: 120, lineHeight, rowsMoved: moved }),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(moved, "one gesture must not scroll twice its physical distance").toBeLessThanOrEqual(
|
||||
Math.ceil(120 / lineHeight),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function dragTerminalWithFinger(
|
||||
page: Page,
|
||||
moves: number,
|
||||
inspect: (state: { lineHeight: number; before: number }) => Promise<void>,
|
||||
) {
|
||||
await test.step(`Drag history with ${moves} touch moves`, async () => {
|
||||
const screen = page
|
||||
.getByTestId("terminal-surface")
|
||||
.filter({ visible: true })
|
||||
.locator(".xterm-screen");
|
||||
const box = await screen.boundingBox();
|
||||
if (!box) throw new Error("Terminal screen is not visible");
|
||||
const rows = await page.evaluate(() => window.__paseoTerminal!.rows);
|
||||
const lineHeight = box.height / rows;
|
||||
const before = (await viewport(page))!;
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
const start = { x: box.x + box.width / 2, y: box.y + box.height / 3 };
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ ...start, id: 1 }],
|
||||
});
|
||||
for (let step = 1; step <= moves; step++) {
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: start.x, y: start.y + (120 * step) / moves, id: 1 }],
|
||||
});
|
||||
}
|
||||
// Check before lifting the finger to exclude inertial scrolling.
|
||||
await inspect({ lineHeight, before });
|
||||
} finally {
|
||||
await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
await session.detach();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import path from "node:path";
|
||||
import {
|
||||
holdOlderHistoryPages,
|
||||
scrollTimelineRowIntoView,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { getActiveTabTestId } from "../support/helpers/launcher";
|
||||
@@ -529,6 +530,7 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
|
||||
.last();
|
||||
await expect(group).toContainText(/read 1 file/i);
|
||||
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
|
||||
await scrollTimelineRowIntoView(page, group);
|
||||
await group.getByRole("button").first().click();
|
||||
// Expand thinking is off: the group opens its rows, but the thought row stays closed
|
||||
// until the reader opens it.
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
expectFooterItemSetting,
|
||||
expectFooterSeparator,
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
@@ -70,7 +71,112 @@ async function footerClip(page: Page) {
|
||||
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
|
||||
}
|
||||
|
||||
async function setPageVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
|
||||
await page.evaluate((state) => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
get: () => state === "hidden",
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}, visibility);
|
||||
}
|
||||
|
||||
test.describe("Usage item", () => {
|
||||
test("visible summary refreshes every five minutes without opening Usage", async ({ page }) => {
|
||||
let usedPct = 31;
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
() => [
|
||||
{
|
||||
...claudeAndCodexReports()[0]!,
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [
|
||||
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(WIDE);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h"]);
|
||||
const startingUrl = page.url();
|
||||
await qaScreenshot(page, "usage-auto-refresh-initial", { kind: "footer" });
|
||||
const requestsBeforeRefresh = usage.listRequests();
|
||||
|
||||
usedPct = 42;
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
await qaScreenshot(page, "usage-auto-refresh-after-interval", { kind: "footer" });
|
||||
await expectPinnedUsage(page, ["42% 5h"]);
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
...requestsBeforeRefresh,
|
||||
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
|
||||
]);
|
||||
|
||||
await setPageVisibility(page, "hidden");
|
||||
const requestsBeforeBackgrounding = usage.listRequests();
|
||||
usedPct = 47;
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeBackgrounding);
|
||||
await expectPinnedUsage(page, ["42% 5h"]);
|
||||
await setPageVisibility(page, "visible");
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
await expectPinnedUsage(page, ["47% 5h"]);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
...requestsBeforeBackgrounding,
|
||||
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
|
||||
]);
|
||||
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(usageItem(page)).toHaveCount(0);
|
||||
const requestsBeforeHiding = usage.listRequests();
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeHiding);
|
||||
|
||||
await openCompactSidebar(page);
|
||||
await expectPinnedUsage(page, ["47% 5h"]);
|
||||
await expect(page.getByTestId("agent-list-backdrop")).toBeVisible();
|
||||
usedPct = 53;
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
await expectPinnedUsage(page, ["53% 5h"]);
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize(WIDE);
|
||||
await openSidebarNavSettings(page);
|
||||
await setFooterItemVisible(page, "usage", false);
|
||||
await leaveSettings(page);
|
||||
await expectNoUsageItem(page);
|
||||
const requestsBeforeDisabling = usage.listRequests();
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeDisabling);
|
||||
});
|
||||
|
||||
test("Usage settings do not poll reports while the sidebar is inactive", async ({ page }) => {
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(COMPACT);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
await openSettingsHostSection(page, getServerId(), "usage");
|
||||
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
|
||||
await expect(usageItem(page)).toHaveCount(0);
|
||||
const requestsBeforeWaiting = usage.listRequests();
|
||||
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeWaiting);
|
||||
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
|
||||
});
|
||||
|
||||
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
|
||||
@@ -357,6 +357,8 @@ test.describe("Viewed agent timelines", () => {
|
||||
});
|
||||
|
||||
test("two visible split chats both stay current", async ({ page }) => {
|
||||
// Keep both split panes wide enough for the tab-rail interactions below.
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await enableMoveTabShortcut(page);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar";
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
archiveFromRowButton,
|
||||
createGatedTeardown,
|
||||
expectDirectoryPresence,
|
||||
expectHostWorkspaceSettledActive,
|
||||
outlastUndoWindow,
|
||||
pressUndoShortcut,
|
||||
rowArchiveButton,
|
||||
rowKebab,
|
||||
undoButton,
|
||||
workspaceRow,
|
||||
workspaceTitleWidth,
|
||||
} from "../support/helpers/workspace-archive-undo";
|
||||
import {
|
||||
waitForSidebarHydration,
|
||||
waitForWorkspaceInSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace archive undo", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const worktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectNewWorkspaceDaemonClient();
|
||||
tempRepo = await createTempGitRepo("archive-undo-");
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const directory of worktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined);
|
||||
}
|
||||
worktreeDirectories.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
async function seedWorktree(page: Page, slug: string) {
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `${slug}-${Date.now()}`,
|
||||
});
|
||||
worktreeDirectories.add(worktree.workspaceDirectory);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: worktree.workspaceId,
|
||||
});
|
||||
return worktree;
|
||||
}
|
||||
|
||||
test("the hover archive button overlays the title instead of narrowing it", async ({ page }) => {
|
||||
const worktree = await seedWorktree(page, "archive-button-overlay");
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(rowArchiveButton(page, worktree.workspaceId)).toHaveCount(0);
|
||||
const restingWidth = await workspaceTitleWidth(page, worktree.workspaceId);
|
||||
|
||||
await workspaceRow(page, worktree.workspaceId).hover();
|
||||
await expect(rowArchiveButton(page, worktree.workspaceId)).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-workspace-trailing-scrim")).toBeVisible();
|
||||
expect(await workspaceTitleWidth(page, worktree.workspaceId)).toBe(restingWidth);
|
||||
|
||||
const [buttonBox, kebabBox] = await Promise.all([
|
||||
rowArchiveButton(page, worktree.workspaceId).boundingBox(),
|
||||
rowKebab(page, worktree.workspaceId).boundingBox(),
|
||||
]);
|
||||
expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true);
|
||||
});
|
||||
|
||||
test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => {
|
||||
const worktree = await seedWorktree(page, "archive-undo-shortcut");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await pressUndoShortcut(page);
|
||||
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
|
||||
await expect(undoButton(page)).toHaveCount(0);
|
||||
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
|
||||
// Once the host settled, any remove the canceled archive sent has already arrived.
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
|
||||
await expectDirectoryPresence(worktree.workspaceDirectory, true);
|
||||
});
|
||||
|
||||
test("Undo after the worktree was removed recreates it", async ({ page }) => {
|
||||
const worktree = await seedWorktree(page, "archive-undo-recreate");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
// Hovering the toast holds it, and with it the undo, open while the archive finishes.
|
||||
await undoButton(page).hover();
|
||||
await expectDirectoryPresence(worktree.workspaceDirectory, false);
|
||||
await undoButton(page).click();
|
||||
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
await expectDirectoryPresence(worktree.workspaceDirectory, true);
|
||||
});
|
||||
|
||||
test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
const worktree = await seedWorktree(page, "archive-undo-hold");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await page.getByTestId("workspace-archived-toast").hover({ position: { x: 8, y: 8 } });
|
||||
await undoButton(page).hover();
|
||||
await outlastUndoWindow(page);
|
||||
await expect(undoButton(page)).toBeVisible();
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await outlastUndoWindow(page);
|
||||
await expect(undoButton(page)).toHaveCount(0);
|
||||
|
||||
// The toast took the undo with it.
|
||||
await pressUndoShortcut(page);
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
});
|
||||
|
||||
test("opening the workspace while its undo is still running keeps its tabs", async ({ page }) => {
|
||||
const teardown = createGatedTeardown();
|
||||
const repo = await createTempGitRepo("archive-undo-tabs-", {
|
||||
paseoConfig: teardown.paseoConfig,
|
||||
});
|
||||
const seedClient = await connectSeedClient();
|
||||
try {
|
||||
await openProjectViaDaemon(client, repo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: repo.path,
|
||||
slug: `archive-undo-tabs-${Date.now()}`,
|
||||
});
|
||||
worktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const target = { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId };
|
||||
const agents = [
|
||||
await createMockIdleAgent(seedClient, { ...target, title: "first" }),
|
||||
await createMockIdleAgent(seedClient, { ...target, title: "second" }),
|
||||
] as const;
|
||||
await openWorkspaceWithAgents(page, [agents[0], agents[1]]);
|
||||
const tabsBefore = await getWorkspaceTabTestIds(page);
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
// The archive has taken its agents down and now parks in teardown.
|
||||
await teardown.expectStarted();
|
||||
await undoButton(page).click();
|
||||
await workspaceRow(page, worktree.workspaceId).click();
|
||||
|
||||
for (const agent of agents) {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agent.id}`).first()).toBeVisible();
|
||||
}
|
||||
teardown.release();
|
||||
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
|
||||
await expect.poll(() => getWorkspaceTabTestIds(page)).toEqual(tabsBefore);
|
||||
} finally {
|
||||
teardown.release();
|
||||
await seedClient.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("Cmd/Ctrl+Z without an archive to undo leaves the sidebar alone", async ({ page }) => {
|
||||
const worktree = await seedWorktree(page, "archive-undo-none");
|
||||
|
||||
await pressUndoShortcut(page);
|
||||
|
||||
await expect(undoButton(page)).toHaveCount(0);
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -101,3 +101,20 @@ test("focus mode only applies to the active workspace screen", async ({ page, wi
|
||||
await expect(exitFocusMode).toHaveCount(0);
|
||||
await expect(settingsButton).toBeVisible();
|
||||
});
|
||||
|
||||
async function exitFocusModeFromCompactHeader(page: Page) {
|
||||
await page.setViewportSize({ width: 400, height: 844 });
|
||||
await page.getByTestId("workspace-header-menu-trigger").click();
|
||||
await page.getByRole("menuitem", { name: "Exit focus mode", exact: true }).click();
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
}
|
||||
|
||||
test("compact workspace menu keeps focus mode exit reachable", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "compact-focus-exit-" });
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await enterFocusMode(page);
|
||||
await exitFocusModeFromCompactHeader(page);
|
||||
await expect(exitFocusModeButton(page)).toHaveCount(0);
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -52,6 +52,8 @@ async function waitForWorkspaceRoute(page: Page, route: string) {
|
||||
test.describe("Workspace pane mounting", () => {
|
||||
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
// Keep both split panes wide enough for the tab-rail interactions below.
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const serverId = getServerId();
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pane-remount-" });
|
||||
|
||||
@@ -6,6 +6,11 @@ env APP_ID=sh.paseo.debug
|
||||
# Hold past drag arming (180ms), then release.
|
||||
open "${APP_ID}"
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"sidebar-close\"" 10000
|
||||
press "id=\"sidebar-close\""
|
||||
# The close target must clear system controls, including a trailing status column.
|
||||
wait "label=\"Open menu\"" 5000
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"${WORKSPACE_ROW}\"" 10000
|
||||
longpress "id=\"${WORKSPACE_ROW}\"" 250
|
||||
press "id=\"${WORKSPACE_ROW}\""
|
||||
|
||||
@@ -117,3 +117,25 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
|
||||
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
|
||||
}
|
||||
|
||||
export async function navigateAndDismissAddProject(page: Page): Promise<void> {
|
||||
// Keep both keys in one task so Escape closes with a selection update still queued.
|
||||
await page.evaluate(() => {
|
||||
for (const key of ["ArrowDown", "Escape"]) {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, code: key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function watchAddProjectRenderWarnings(page: Page): string[] {
|
||||
const warnings: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (text.includes("Cannot update a component") && text.includes("AddProjectFlow")) {
|
||||
warnings.push(text);
|
||||
}
|
||||
});
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
|
||||
/**
|
||||
* Starts a background agent the way a plugin helper does: in the workspace, under a parent,
|
||||
* streaming for about ten seconds. Resolves once the daemon reports it running.
|
||||
*/
|
||||
export async function startBackgroundAgent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
input: { parentId: string; title: string },
|
||||
): Promise<{ id: string }> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "classify the last turn",
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: input.parentId },
|
||||
background: true,
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running");
|
||||
return { id: agent.id };
|
||||
}
|
||||
|
||||
/** Waits for the agent's turn to end and archives it, as the plugin does with a helper's reply. */
|
||||
export async function finishAndArchiveAgent(
|
||||
workspace: Pick<SeededWorkspace, "client">,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
const finished = await workspace.client.waitForFinish(agentId, 60_000);
|
||||
expect(finished.status).toBe("idle");
|
||||
await workspace.client.archiveAgent(agentId);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceShownIdle(page: Page, workspaceName: string): Promise<void> {
|
||||
const row = page.getByRole("button", {
|
||||
name: new RegExp(`^${escapeRegExp(workspaceName)}(?:,|$)`),
|
||||
});
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByTestId("workspace-status-indicator-running")).toHaveCount(0);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -53,3 +53,192 @@ export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise
|
||||
const collapsed = frames.filter((frame) => frame.labels.some((label) => label === ""));
|
||||
expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]);
|
||||
}
|
||||
|
||||
/** What the folded model control's labels show, read from the DOM. */
|
||||
export interface ModelTriggerLabelState {
|
||||
thinkingShown: boolean;
|
||||
modelShown: boolean;
|
||||
modelText: string;
|
||||
modelTruncated: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__modelTriggerFrames?: ModelTriggerLabelState[];
|
||||
}
|
||||
}
|
||||
|
||||
function readModelTriggerLabelStateInPage(): ModelTriggerLabelState {
|
||||
const visible = (node: Element) => node.getClientRects().length > 0;
|
||||
const model = Array.from(
|
||||
document.querySelectorAll('[data-testid="combined-model-selector-model-label"]'),
|
||||
).find(visible);
|
||||
const thinking = Array.from(
|
||||
document.querySelectorAll('[data-testid="combined-model-selector-thinking-label"]'),
|
||||
).find(visible);
|
||||
const shown = (node: Element | null): boolean => {
|
||||
for (let current = node; current; current = current.parentElement) {
|
||||
if (Number(getComputedStyle(current).opacity) === 0) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const modelShown = model !== undefined && shown(model);
|
||||
const modelText = model?.textContent ?? "";
|
||||
return {
|
||||
modelShown,
|
||||
modelText,
|
||||
thinkingShown: modelShown && thinking !== undefined,
|
||||
modelTruncated:
|
||||
model !== undefined && (modelText.endsWith("…") || model.scrollWidth > model.clientWidth),
|
||||
};
|
||||
}
|
||||
|
||||
/** Records the folded model control's labels on every painted frame from page load. */
|
||||
export async function recordModelTriggerFramesFromLoad(page: Page): Promise<void> {
|
||||
await page.addInitScript(`(() => {
|
||||
const read = ${readModelTriggerLabelStateInPage.toString()};
|
||||
const frames = [];
|
||||
window.__modelTriggerFrames = frames;
|
||||
const tick = () => {
|
||||
frames.push(read());
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
})()`);
|
||||
}
|
||||
|
||||
/** The model control's labels once two reads 100ms apart agree. */
|
||||
export async function readSettledModelTriggerLabels(page: Page): Promise<ModelTriggerLabelState> {
|
||||
let previous = await page.evaluate(readModelTriggerLabelStateInPage);
|
||||
for (;;) {
|
||||
await page.waitForTimeout(100);
|
||||
const next = await page.evaluate(readModelTriggerLabelStateInPage);
|
||||
if (JSON.stringify(next) === JSON.stringify(previous)) return next;
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
|
||||
/** The labels at the widths where the folded model control changes what it shows. */
|
||||
export interface ModelTriggerLabelSweep {
|
||||
/** The labels at the widest width. */
|
||||
widest: ModelTriggerLabelState & { width: number };
|
||||
/** The first width at which the thinking label is hidden. */
|
||||
thinkingHidden: ModelTriggerLabelState & { width: number };
|
||||
/** The first width at which the model label is truncated. */
|
||||
modelTruncated: ModelTriggerLabelState & { width: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows the viewport step by step and records where the thinking label disappears and where the
|
||||
* model label first truncates.
|
||||
*/
|
||||
export async function sweepModelTriggerLabels(
|
||||
page: Page,
|
||||
widths: { from: number; to: number; step: number; height: number },
|
||||
): Promise<ModelTriggerLabelSweep> {
|
||||
const steps: (ModelTriggerLabelState & { width: number })[] = [];
|
||||
for (let width = widths.from; width >= widths.to; width -= widths.step) {
|
||||
await page.setViewportSize({ width, height: widths.height });
|
||||
const labels = await readSettledModelTriggerLabels(page);
|
||||
steps.push({ ...labels, width });
|
||||
if (labels.modelTruncated || !labels.modelShown) break;
|
||||
}
|
||||
const thinkingHidden = steps.find((step) => !step.thinkingShown);
|
||||
const modelTruncated = steps.find((step) => step.modelTruncated);
|
||||
if (!thinkingHidden || !modelTruncated) {
|
||||
throw new Error(`the sweep never hid the thinking label and truncated the model label`);
|
||||
}
|
||||
return { widest: steps[0], thinkingHidden, modelTruncated };
|
||||
}
|
||||
|
||||
/** No frame recorded so far painted a truncated model label beside the thinking label. */
|
||||
export async function expectNoFrameTruncatesModelBesideThinking(page: Page): Promise<void> {
|
||||
const frames = await page.evaluate(() => window.__modelTriggerFrames ?? []);
|
||||
expect(frames.some((frame) => frame.modelShown)).toBe(true);
|
||||
const wrong = frames
|
||||
.map((frame, index) => ({ ...frame, index }))
|
||||
.filter((frame) => frame.thinkingShown && frame.modelTruncated);
|
||||
expect(wrong, `of ${frames.length} frames`).toEqual([]);
|
||||
}
|
||||
|
||||
/** Where the composer toolbar's ends sit against the composer, in CSS pixels. */
|
||||
export interface ComposerToolbarRails {
|
||||
/** Where the text column starts. */
|
||||
textLeft: number;
|
||||
/** Where the toolbar's leftmost ink starts. */
|
||||
inkLeft: number;
|
||||
/** How far the trailing disc sits from the composer's right edge. */
|
||||
discRightInset: number;
|
||||
/** How far the trailing disc sits from the composer's bottom edge. */
|
||||
discBottomInset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads where the toolbar's ink starts against the text column and where the trailing disc sits
|
||||
* in the composer's corner. A control's ink is its filled disc when it has one, otherwise its
|
||||
* glyph's stroked geometry.
|
||||
*/
|
||||
export async function readComposerToolbarRails(page: Page): Promise<ComposerToolbarRails> {
|
||||
const root = page.locator('[data-testid="message-input-root"]:visible').first();
|
||||
return root.evaluate((element) => {
|
||||
const textarea = element.querySelector("textarea");
|
||||
if (!textarea) throw new Error("composer has no text input");
|
||||
const composerRect = element.getBoundingClientRect();
|
||||
const textLeft =
|
||||
textarea.getBoundingClientRect().left +
|
||||
Number.parseFloat(getComputedStyle(textarea).paddingLeft);
|
||||
|
||||
const isFilled = (node: Element) => {
|
||||
const color = getComputedStyle(node).backgroundColor;
|
||||
return color !== "" && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color);
|
||||
};
|
||||
const glyphInkLeft = (svg: SVGSVGElement) => {
|
||||
let left = Number.POSITIVE_INFINITY;
|
||||
for (const shape of Array.from(
|
||||
svg.querySelectorAll<SVGGraphicsElement>("path, circle, rect, line, polyline"),
|
||||
)) {
|
||||
const matrix = shape.getScreenCTM();
|
||||
if (!matrix) continue;
|
||||
const box = shape.getBBox();
|
||||
const stroke = Number.parseFloat(getComputedStyle(shape).strokeWidth || "0");
|
||||
left = Math.min(left, matrix.a * (box.x - stroke / 2) + matrix.e);
|
||||
}
|
||||
return left;
|
||||
};
|
||||
|
||||
const band = composerRect.bottom - 64;
|
||||
let inkLeft = Number.POSITIVE_INFINITY;
|
||||
let trailingDisc: DOMRect | null = null;
|
||||
for (const control of Array.from(element.querySelectorAll('[role="button"]'))) {
|
||||
const rect = control.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.top < band) continue;
|
||||
const disc = [control, ...Array.from(control.querySelectorAll("div"))].find(isFilled);
|
||||
const svg = control.querySelector("svg");
|
||||
if (disc) {
|
||||
const discRect = disc.getBoundingClientRect();
|
||||
inkLeft = Math.min(inkLeft, discRect.left);
|
||||
if (!trailingDisc || discRect.right > trailingDisc.right) trailingDisc = discRect;
|
||||
} else if (svg) {
|
||||
inkLeft = Math.min(inkLeft, glyphInkLeft(svg));
|
||||
}
|
||||
}
|
||||
if (!trailingDisc) throw new Error("composer toolbar has no trailing disc");
|
||||
return {
|
||||
textLeft,
|
||||
inkLeft,
|
||||
discRightInset: composerRect.right - trailingDisc.right,
|
||||
discBottomInset: composerRect.bottom - trailingDisc.bottom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The toolbar's left ink sits on the text column's edge, and its trailing disc nests into the
|
||||
* corner, as far from the right edge as from the bottom edge. Within half a pixel.
|
||||
*/
|
||||
export async function expectComposerToolbarOnTextRails(page: Page): Promise<void> {
|
||||
const rails = await readComposerToolbarRails(page);
|
||||
// A precision of 0 digits passes within half a pixel.
|
||||
expect(rails.inkLeft).toBeCloseTo(rails.textLeft, 0);
|
||||
expect(rails.discRightInset).toBeCloseTo(rails.discBottomInset, 0);
|
||||
}
|
||||
|
||||
@@ -54,12 +54,15 @@ export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Compact screens open the meter's details in a sheet. */
|
||||
export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
/** Clicking the meter opens its details in the active layout. */
|
||||
export async function pressContextWindowMeter(
|
||||
page: Page,
|
||||
name: string | RegExp = METER_NAME,
|
||||
): Promise<Locator> {
|
||||
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
|
||||
const sheet = contextWindowDetails(page);
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
const details = contextWindowDetails(page);
|
||||
await expect(details).toBeVisible();
|
||||
return details;
|
||||
}
|
||||
|
||||
export async function closeContextWindowSheet(page: Page): Promise<void> {
|
||||
|
||||
@@ -22,15 +22,27 @@ export async function addOfflineHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
const offlineHost = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await addOfflineHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
|
||||
}
|
||||
|
||||
/** Adds several unreachable hosts with one reload. See `addOfflineHostAndReload`. */
|
||||
export async function addOfflineHostsAndReload(
|
||||
page: Page,
|
||||
hosts: Array<{ serverId: string; label: string }>,
|
||||
options: { primaryLabel?: string } = {},
|
||||
): Promise<void> {
|
||||
const nowIso = new Date().toISOString();
|
||||
const offlineHosts = hosts.map((host) =>
|
||||
buildSeededHost({
|
||||
serverId: host.serverId,
|
||||
label: host.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso,
|
||||
}),
|
||||
);
|
||||
|
||||
await page.evaluate(
|
||||
({ host, keys, primaryLabel }) => {
|
||||
({ hosts: added, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
@@ -40,20 +52,22 @@ export async function addOfflineHostAndReload(
|
||||
if (primaryLabel && registry[0]) {
|
||||
registry[0].label = primaryLabel;
|
||||
}
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
for (const host of added) {
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
host: offlineHost,
|
||||
hosts: offlineHosts,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: input.primaryLabel,
|
||||
primaryLabel: options.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -34,6 +34,54 @@ export async function openAppAsNewUser(
|
||||
await page.goto("/");
|
||||
}
|
||||
|
||||
interface FirstLaunchRecord {
|
||||
appBeforeSetup: boolean;
|
||||
setupOpacityOnArrival: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the page from its first paint: whether the app's sidebar or home showed before setup,
|
||||
* and how opaque setup was when it arrived. Call before `openAppAsNewUser`.
|
||||
*/
|
||||
export async function recordFirstLaunch(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const record: FirstLaunchRecord = { appBeforeSetup: false, setupOpacityOnArrival: null };
|
||||
Object.assign(window, { __firstLaunch: record });
|
||||
const observer = new MutationObserver(() => {
|
||||
const setup = document.querySelector('[data-testid="onboarding-modal"] [role="dialog"]');
|
||||
if (setup) {
|
||||
record.setupOpacityOnArrival = Number(getComputedStyle(setup).opacity);
|
||||
observer.disconnect();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
document.querySelector(
|
||||
'[data-testid="open-project-submit"], [data-testid="sidebar-settings"]',
|
||||
)
|
||||
) {
|
||||
record.appBeforeSetup = true;
|
||||
}
|
||||
});
|
||||
observer.observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function readFirstLaunch(page: Page): Promise<FirstLaunchRecord> {
|
||||
return page.evaluate(
|
||||
() => (window as unknown as { __firstLaunch: FirstLaunchRecord }).__firstLaunch,
|
||||
);
|
||||
}
|
||||
|
||||
/** The loading screen the app shows while it starts. */
|
||||
export function loadingScreen(page: Page): Locator {
|
||||
return page.getByTestId("startup-splash");
|
||||
}
|
||||
|
||||
/** The empty home a host with no projects lands on. */
|
||||
export function emptyHome(page: Page): Locator {
|
||||
return page.getByTestId("open-project-submit");
|
||||
}
|
||||
|
||||
export function setupModal(page: Page): Locator {
|
||||
return page.getByTestId("onboarding-modal").getByRole("dialog");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { scrollAgentChatToBottom, waitForScrollableChat } from "./agent-bottom-anchor";
|
||||
import { expectTurnCopyButton } from "./agent-stream";
|
||||
import { submitMessage } from "./composer";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import {
|
||||
@@ -88,7 +91,7 @@ export default function contribute(client) {
|
||||
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
|
||||
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
|
||||
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
const usage = pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
|
||||
command("finish", "Finish deployment", () => finish());
|
||||
command("finish-review", "Finish review", () => finishReview());
|
||||
@@ -102,10 +105,16 @@ export default function contribute(client) {
|
||||
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
|
||||
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
|
||||
const lifetime = new AbortController();
|
||||
let pillsHidden = false;
|
||||
const applyWorkspace = (workspace) => {
|
||||
if (workspace.id !== workspaceId) return;
|
||||
const hidePills = workspace.name === "No composer pills";
|
||||
deploy.update({ visible: workspace.name !== "Compact checks" });
|
||||
pillMenu.update({ visible: workspace.name !== "Hide composer menu" });
|
||||
pillMenu.update({ visible: workspace.name !== "Hide composer menu" && !hidePills });
|
||||
if (hidePills === pillsHidden) return;
|
||||
pillsHidden = hidePills;
|
||||
review.update({ visible: !hidePills });
|
||||
usage.update({ visible: !hidePills });
|
||||
};
|
||||
void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
|
||||
subscription.subscribe({
|
||||
@@ -244,6 +253,38 @@ async function captureSchemes(page: Page, info: TestInfo, name: string, subject:
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
}
|
||||
|
||||
const COMPOSER_PILLS = ["Run review", "Composer checks", "Composer status"] as const;
|
||||
|
||||
function chevronOf(page: Page, title: string): Locator {
|
||||
return page
|
||||
.getByRole("button", { name: title, exact: true })
|
||||
.getByTestId("plugin-button-chevron");
|
||||
}
|
||||
|
||||
/** Composer pills that open a menu or popover carry a chevron; a one-tap action does not. */
|
||||
async function expectComposerPillChevrons(page: Page) {
|
||||
await expect(chevronOf(page, "Composer checks")).toHaveCount(1);
|
||||
await expect(chevronOf(page, "Composer status")).toHaveCount(1);
|
||||
await expect(chevronOf(page, "Run review")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function setComposerPillsShown(
|
||||
page: Page,
|
||||
showcase: { setTitle(title: string): Promise<unknown> },
|
||||
shown: boolean,
|
||||
) {
|
||||
await showcase.setTitle(shown ? "Button showcase" : "No composer pills");
|
||||
for (const title of COMPOSER_PILLS) {
|
||||
await expect(page.getByRole("button", { name: title, exact: true })).toHaveCount(shown ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function lastTurnTop(page: Page): Promise<number> {
|
||||
const box = await page.getByRole("button", { name: "Copy turn" }).last().boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
return box!.y;
|
||||
}
|
||||
|
||||
async function expectHeaderTextTruncated(page: Page) {
|
||||
const title = page.getByTestId("workspace-header-title");
|
||||
const subtitle = page.getByTestId("workspace-header-subtitle");
|
||||
@@ -276,6 +317,7 @@ export async function withButtonShowcase(
|
||||
run: (buttons: {
|
||||
openWideMenusAndPopovers(): Promise<void>;
|
||||
runAndUpdateActions(): Promise<void>;
|
||||
keepTrackSpaceWithoutPills(): Promise<void>;
|
||||
openCompactSheets(): Promise<void>;
|
||||
useCompactWorkspaceMenu(): Promise<void>;
|
||||
hideAndUnloadOpenButtons(): Promise<void>;
|
||||
@@ -348,11 +390,7 @@ export async function withButtonShowcase(
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Composer checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await expectComposerPillChevrons(page);
|
||||
await expectSurfaceVisible(page);
|
||||
await captureSchemes(
|
||||
page,
|
||||
@@ -476,10 +514,31 @@ export async function withButtonShowcase(
|
||||
"Review ready",
|
||||
);
|
||||
}),
|
||||
keepTrackSpaceWithoutPills: () =>
|
||||
test.step("the composer track keeps its space when every pill leaves and returns", async () => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await submitMessage(page, "Stream a turn under the composer pills");
|
||||
await expectTurnCopyButton(page);
|
||||
await waitForScrollableChat(page, { minScrollableDistance: 200, timeout: 30_000 });
|
||||
for (const viewport of [WIDE, COMPACT]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await scrollAgentChatToBottom(page);
|
||||
const top = await lastTurnTop(page);
|
||||
await setComposerPillsShown(page, showcase, false);
|
||||
expect(await lastTurnTop(page)).toBe(top);
|
||||
await setComposerPillsShown(page, showcase, true);
|
||||
expect(await lastTurnTop(page)).toBe(top);
|
||||
}
|
||||
await page.setViewportSize(WIDE);
|
||||
}),
|
||||
openCompactSheets: () =>
|
||||
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expectComposerPillChevrons(page);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer checks", exact: true }),
|
||||
).toContainText("Checks");
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||
@@ -9,6 +12,54 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
const TABBED_PLUGIN_ID = "settings-tabs";
|
||||
const TABBED_PLUGIN_NAME = "Tabbed settings";
|
||||
/** Two settings screens, so the settings page shows a tab per screen. */
|
||||
const TABBED_SOURCE = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { defineTranslations, useTranslation } from "@getpaseo/plugin/client";
|
||||
|
||||
const messages = defineTranslations({ resources: { en: { icon: "advanced-tab-icon" } } });
|
||||
|
||||
function General({ theme }) {
|
||||
return <Text style={{ color: theme.colors.foreground }}>General settings body</Text>;
|
||||
}
|
||||
|
||||
function Advanced({ theme }) {
|
||||
return <Text style={{ color: theme.colors.foreground }}>Advanced settings body</Text>;
|
||||
}
|
||||
|
||||
// Component icons run with the plugin's runtime; one that throws costs its tab only the icon.
|
||||
function TranslatedIcon({ color, size }) {
|
||||
const { t } = useTranslation(messages);
|
||||
return <View testID={t("icon")} aria-hidden style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
function BrokenIcon() {
|
||||
throw new Error("Settings tab icon exploded");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addSettingsScreen({ id: "general", title: "General", icon: BrokenIcon, Component: General });
|
||||
client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: TranslatedIcon, Component: Advanced });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
async function writeTabbedPlugin() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${TABBED_PLUGIN_ID}-`));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: TABBED_PLUGIN_ID,
|
||||
name: TABBED_PLUGIN_NAME,
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), TABBED_SOURCE);
|
||||
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
settingsPlugin: [
|
||||
async ({ e2eWorker, page }, provide, testInfo) => {
|
||||
@@ -16,16 +67,20 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("settings");
|
||||
const tabbed = await writeTabbedPlugin();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await client.installDirectoryPlugin(tabbed.directory);
|
||||
await provide(client);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings.png") });
|
||||
} finally {
|
||||
await client.removePlugin("settings-example");
|
||||
await client.removePlugin(TABBED_PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await tabbed.cleanup();
|
||||
}
|
||||
},
|
||||
{ auto: true },
|
||||
@@ -44,9 +99,88 @@ export async function openCompactDisplaySettings(page: Page) {
|
||||
}
|
||||
async function openPluginScreen(page: Page) {
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await openSettingsFromCog(page);
|
||||
}
|
||||
/** The cog on a plugin's row. Its label is "Settings"; the row tells plugins apart. */
|
||||
function settingsButton(page: Page, pluginId: string) {
|
||||
return page
|
||||
.getByTestId(`plugin-row-${pluginId}`)
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Settings", exact: true });
|
||||
}
|
||||
async function openSettingsFromCog(page: Page) {
|
||||
await settingsButton(page, "settings-example").click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
// A plugin with one settings screen shows no tabs, and the header names only the plugin.
|
||||
await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0);
|
||||
await expectSettingsHeader(page, "settings-example");
|
||||
}
|
||||
export async function openPluginsPage(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
}
|
||||
export async function expectActionsMenuWithoutSettings(page: Page) {
|
||||
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Display", exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
export async function expectNoSettingsCogWithoutScreens(page: Page) {
|
||||
await page.getByRole("switch", { name: "settings-example: Disable" }).click();
|
||||
await expect(page.getByRole("switch", { name: "settings-example: Enable" })).toBeVisible();
|
||||
await expect(settingsButton(page, "settings-example")).toHaveCount(0);
|
||||
await page.getByRole("switch", { name: "settings-example: Enable" }).click();
|
||||
await expect(settingsButton(page, "settings-example")).toBeVisible();
|
||||
}
|
||||
export async function expectSettingsTooltip(page: Page) {
|
||||
await settingsButton(page, "settings-example").hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Settings");
|
||||
await page.mouse.move(0, 0);
|
||||
}
|
||||
/** A manifest `name` titles the row and its controls; the id stays visible beneath it. */
|
||||
export async function expectNamedPluginRow(page: Page) {
|
||||
const row = page.getByTestId(`plugin-row-${TABBED_PLUGIN_ID}`).filter({ visible: true });
|
||||
await expect(row.getByText(TABBED_PLUGIN_NAME, { exact: true })).toBeVisible();
|
||||
await expect(row.getByText(TABBED_PLUGIN_ID, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("switch", { name: `${TABBED_PLUGIN_NAME}: Disable`, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Actions for ${TABBED_PLUGIN_NAME}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
export async function openTabbedSettings(page: Page) {
|
||||
await settingsButton(page, TABBED_PLUGIN_ID).click();
|
||||
await expectSettingsTab(page, "General");
|
||||
// The Advanced icon reads the plugin's translations; the General icon throws and is dropped.
|
||||
await expect(page.getByTestId("advanced-tab-icon")).toBeVisible();
|
||||
}
|
||||
function settingsTab(page: Page, title: string) {
|
||||
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
|
||||
}
|
||||
export async function selectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
await settingsTab(page, title).click();
|
||||
await expectSettingsTab(page, title);
|
||||
}
|
||||
export async function expectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
const other = title === "General" ? "Advanced" : "General";
|
||||
await expect(settingsTab(page, title)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByText(`${title} settings body`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(`${other} settings body`, { exact: true })).toHaveCount(0);
|
||||
// The header names the plugin by its manifest name; the tab names the screen.
|
||||
await expectSettingsHeader(page, TABBED_PLUGIN_NAME);
|
||||
await expectAppRoute(
|
||||
page,
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") {
|
||||
await gotoAppShell(page);
|
||||
await page.goto(
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
await expectSettingsTab(page, title);
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
await page.getByRole("button", { name: /^Group agents by:/ }).click();
|
||||
@@ -101,11 +235,7 @@ export async function discardTitle(page: Page) {
|
||||
}
|
||||
export async function returnAndReopenSettings(page: Page) {
|
||||
await goBackInSettings(page);
|
||||
const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true });
|
||||
await expect(actions).toBeVisible();
|
||||
await actions.click();
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
await openSettingsFromCog(page);
|
||||
}
|
||||
export async function returnToPluginsPage(page: Page) {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
@@ -114,6 +244,10 @@ export async function returnToPluginsPage(page: Page) {
|
||||
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function expectSettingsHeader(page: Page, title: string) {
|
||||
// Settings keeps the Plugins page mounted beneath, so read the visible title only.
|
||||
await expect(page.getByText(title, { exact: true }).filter({ visible: true })).toHaveCount(1);
|
||||
}
|
||||
export async function expectSettingsUnavailable(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface SeedDaemonClient {
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
labels?: Record<string, string>;
|
||||
background?: boolean;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/** Every footer control that can sit in the icon line, caret included. */
|
||||
const FOOTER_LINE_TEST_IDS = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-usage-icon",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-footer-overflow",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
|
||||
const GEOMETRY_TOLERANCE = 0.01;
|
||||
|
||||
/** Open the app with the docked sidebar at `width`, as if the user had dragged it there. */
|
||||
export async function seedDockedSidebarWidth(page: Page, width: number): Promise<void> {
|
||||
await page.addInitScript((sidebarWidth) => {
|
||||
localStorage.setItem("panel-state", JSON.stringify({ version: 17, state: { sidebarWidth } }));
|
||||
}, width);
|
||||
}
|
||||
|
||||
/**
|
||||
* From the first frame, record any frame in which a footer button reaches past its row, so a row
|
||||
* laid out before it measured shows up even if it lasted one frame.
|
||||
*/
|
||||
export async function watchFooterClipping(page: Page): Promise<void> {
|
||||
await page.addInitScript((testIDs) => {
|
||||
const clippedFrames: string[] = [];
|
||||
Object.assign(window, { __footerClippedFrames: clippedFrames });
|
||||
const sample = () => {
|
||||
for (const row of document.querySelectorAll('[data-testid="sidebar-footer-bottom-line"]')) {
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
if (rowRect.width === 0) continue;
|
||||
for (const testID of testIDs) {
|
||||
const button = row.querySelector(`[data-testid="${testID}"]`);
|
||||
if (button && button.getBoundingClientRect().right > rowRect.right + 0.5) {
|
||||
clippedFrames.push(testID);
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
}, FOOTER_LINE_TEST_IDS);
|
||||
}
|
||||
|
||||
export async function expectFooterNeverClipped(page: Page): Promise<void> {
|
||||
const clipped = await page.evaluate(
|
||||
() => (window as unknown as { __footerClippedFrames: string[] }).__footerClippedFrames,
|
||||
);
|
||||
expect(clipped).toEqual([]);
|
||||
}
|
||||
|
||||
function footerLine(page: Page): Locator {
|
||||
return page.locator('[data-testid="sidebar-footer-bottom-line"]:visible');
|
||||
}
|
||||
|
||||
export function footerOverflowMenu(page: Page): Locator {
|
||||
return page.getByTestId("sidebar-footer-overflow-menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon line shows exactly `testIDs`, in order, each `controlSize` square and wholly inside the
|
||||
* line's box, so no button is cut off by the sidebar's edge.
|
||||
*/
|
||||
export async function expectFooterLine(
|
||||
page: Page,
|
||||
input: { testIDs: string[]; controlSize: number },
|
||||
): Promise<void> {
|
||||
const line = footerLine(page);
|
||||
await expect(line).toHaveCount(1);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
line
|
||||
.locator(FOOTER_LINE_TEST_IDS.map((testID) => `[data-testid="${testID}"]`).join(","))
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))),
|
||||
)
|
||||
.toEqual(input.testIDs);
|
||||
const { row, buttons } = await line.evaluate((element, testIDs) => {
|
||||
const rect = (node: Element) => {
|
||||
const { x, width, height } = node.getBoundingClientRect();
|
||||
return { x, width, height };
|
||||
};
|
||||
return {
|
||||
row: rect(element),
|
||||
buttons: testIDs.map((testID) => rect(element.querySelector(`[data-testid="${testID}"]`)!)),
|
||||
};
|
||||
}, input.testIDs);
|
||||
for (const button of buttons) {
|
||||
expect(Math.abs(button.width - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
|
||||
expect(Math.abs(button.height - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
|
||||
expect(button.x).toBeGreaterThanOrEqual(row.x - GEOMETRY_TOLERANCE);
|
||||
expect(button.x + button.width).toBeLessThanOrEqual(row.x + row.width + GEOMETRY_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFooterOverflow(page: Page): Promise<void> {
|
||||
await footerLine(page).getByTestId("sidebar-footer-overflow").click();
|
||||
await expect(footerOverflowMenu(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** The overflow menu lists exactly these actions, in row order. */
|
||||
export async function expectFooterOverflowActions(page: Page, names: string[]): Promise<void> {
|
||||
await expect(footerOverflowMenu(page).getByRole("menuitem")).toHaveText(names);
|
||||
}
|
||||
|
||||
export async function chooseFooterOverflowAction(page: Page, name: string): Promise<void> {
|
||||
await footerOverflowMenu(page).getByRole("menuitem", { name }).click();
|
||||
}
|
||||
|
||||
/** Every row of the open overflow menu, and of the page it opened, is inside the viewport's width. */
|
||||
export async function expectFooterMenusInsideViewport(page: Page): Promise<void> {
|
||||
const viewport = page.viewportSize()!;
|
||||
const rows = page.getByRole("menuitem");
|
||||
await expect(rows.first()).toBeVisible();
|
||||
const boxes = await rows.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const { x, width } = element.getBoundingClientRect();
|
||||
return { x, width };
|
||||
}),
|
||||
);
|
||||
for (const box of boxes) {
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
|
||||
}
|
||||
}
|
||||
@@ -71,8 +71,23 @@ export async function focusWorkspaceRowWithKeyboard(
|
||||
await expect(row).toBeFocused();
|
||||
}
|
||||
|
||||
export async function scrollWorkspaceList(page: Page): Promise<void> {
|
||||
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const before = await list.evaluate((element) => element.scrollTop);
|
||||
await list.evaluate((element) =>
|
||||
element.scrollBy({ top: element.scrollTop > 0 ? -40 : 40, behavior: "instant" }),
|
||||
);
|
||||
await expect.poll(() => list.evaluate((element) => element.scrollTop)).not.toBe(before);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||
const cards = page.getByTestId("workspace-hover-card");
|
||||
// Keyboard navigation can leave the previous card closing while the next opens.
|
||||
// Retry both checks together: another card may mount between count and visibility.
|
||||
await expect(async () => {
|
||||
expect(await cards.count()).toBe(1);
|
||||
expect(await cards.isVisible()).toBe(true);
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
|
||||
@@ -252,16 +267,9 @@ export async function expectMobileAgentSidebarHidden(page: Page): Promise<void>
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */
|
||||
export async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error("viewport size is required");
|
||||
const y = Math.round(viewport.height / 2);
|
||||
await page.mouse.move(8, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(40, y, { steps: 4 });
|
||||
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
/** Opens navigation without moving focus to the header, including over a fullscreen editor. */
|
||||
export async function openMobileAgentSidebarWithKeyboard(page: Page): Promise<void> {
|
||||
await page.keyboard.press("ControlOrMeta+b");
|
||||
}
|
||||
|
||||
/** Drag from the right edge to open the compact Explorer over the agent. */
|
||||
|
||||
@@ -477,18 +477,29 @@ export async function expectStableHistoryStartGutter(page: Page): Promise<void>
|
||||
.toBe(32);
|
||||
}
|
||||
|
||||
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
|
||||
/**
|
||||
* Wheels the timeline until `row` is centered, as a reader does. Playwright's own
|
||||
* scroll-into-view is programmatic, which the timeline does not treat as the reader leaving
|
||||
* the bottom: it snaps back to the bottom and can move the row away mid-click.
|
||||
*/
|
||||
export async function scrollTimelineRowIntoView(page: Page, row: Locator): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const row = timeline.getByTestId("user-message").filter({ hasText: prompt });
|
||||
const [viewport, target] = await Promise.all([timeline.boundingBox(), row.boundingBox()]);
|
||||
if (!viewport || !target) {
|
||||
throw new Error(`Expected a rendered timeline prompt for ${prompt}`);
|
||||
throw new Error("Expected a rendered timeline row");
|
||||
}
|
||||
// Programmatic scrolling does not express the user's intent to stop following output.
|
||||
await timeline.hover();
|
||||
await page.mouse.wheel(0, target.y - viewport.y - (viewport.height - target.height) / 2);
|
||||
await expect(row).toBeInViewport({ ratio: 1 });
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
}
|
||||
|
||||
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scrollTimelineRowIntoView(
|
||||
page,
|
||||
timeline.getByTestId("user-message").filter({ hasText: prompt }),
|
||||
);
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
.toBeGreaterThan(HISTORY_START_THRESHOLD_PX);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "../fixtures";
|
||||
import type { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectWorkspaceAbsentFromSidebar } from "./sidebar";
|
||||
|
||||
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z";
|
||||
/** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */
|
||||
const UNDO_WINDOW_MS = 6000;
|
||||
|
||||
export function workspaceRow(page: Page, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
export function rowArchiveButton(page: Page, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-archive-button-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
export function rowKebab(page: Page, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
export function undoButton(page: Page) {
|
||||
return page.getByRole("button", { name: /^Undo/ });
|
||||
}
|
||||
|
||||
export async function workspaceTitleWidth(page: Page, workspaceId: string): Promise<number> {
|
||||
const box = await page
|
||||
.getByTestId(`sidebar-workspace-title-${getServerId()}:${workspaceId}`)
|
||||
.boundingBox();
|
||||
if (!box) throw new Error(`Title of ${workspaceId} is not laid out`);
|
||||
return box.width;
|
||||
}
|
||||
|
||||
/** Archives a workspace with its row's hover button and waits for the undo toast. */
|
||||
export async function archiveFromRowButton(page: Page, workspaceId: string): Promise<void> {
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await workspaceRow(page, workspaceId).hover();
|
||||
await rowArchiveButton(page, workspaceId).click();
|
||||
await expectWorkspaceAbsentFromSidebar(page, workspaceId);
|
||||
await expect(undoButton(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function pressUndoShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press(UNDO_SHORTCUT);
|
||||
}
|
||||
|
||||
/** Moves the page clock past the moment an unheld archived toast would have closed. */
|
||||
export async function outlastUndoWindow(page: Page): Promise<void> {
|
||||
await page.clock.fastForward(UNDO_WINDOW_MS + 1000);
|
||||
}
|
||||
|
||||
/** Waits until the host lists the workspace as active with no archive running on it. */
|
||||
export async function expectHostWorkspaceSettledActive(
|
||||
client: DaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { entries } = await client.fetchWorkspaces();
|
||||
const entry = entries.find((candidate) => candidate.id === workspaceId);
|
||||
return entry ? { active: true, archiving: Boolean(entry.archivingAt) } : null;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual({ active: true, archiving: false });
|
||||
}
|
||||
|
||||
export async function expectDirectoryPresence(directory: string, present: boolean): Promise<void> {
|
||||
await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present);
|
||||
}
|
||||
|
||||
export interface GatedTeardown {
|
||||
/** The `paseo.json` config whose worktree teardown parks until released. */
|
||||
paseoConfig: Record<string, unknown>;
|
||||
expectStarted(): Promise<void>;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/** A worktree teardown that parks an archive mid-flight, so an undo lands while it runs. */
|
||||
export function createGatedTeardown(): GatedTeardown {
|
||||
const gateDir = mkdtempSync(path.join(tmpdir(), "archive-undo-gate-"));
|
||||
const startedPath = path.join(gateDir, "started");
|
||||
const releasePath = path.join(gateDir, "release");
|
||||
const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${startedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`;
|
||||
return {
|
||||
paseoConfig: { worktree: { teardown: [teardown] } },
|
||||
expectStarted: () => expectDirectoryPresence(startedPath, true),
|
||||
release: () => writeFileSync(releasePath, "release\n"),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -380,3 +382,21 @@ export function observeSetupPanelTraffic(page: Page) {
|
||||
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
|
||||
return detail.commands.map((command) => command.log).join("\n");
|
||||
}
|
||||
|
||||
/** Hold this isolated daemon's terminal worker so script exit cannot race a layout change. */
|
||||
export async function pauseWorkspaceTerminalWorker(terminalId: string): Promise<() => void> {
|
||||
const home = process.env.E2E_PASEO_HOME;
|
||||
if (!home) throw new Error("Missing isolated E2E home");
|
||||
const directory = path.join(home, "terminal-worker");
|
||||
for (const name of await readdir(directory)) {
|
||||
if (!name.endsWith(".json")) continue;
|
||||
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
|
||||
pid: number;
|
||||
terminals: Array<{ id: string }>;
|
||||
};
|
||||
if (!record.terminals.some((terminal) => terminal.id === terminalId)) continue;
|
||||
process.kill(record.pid, "SIGSTOP");
|
||||
return () => process.kill(record.pid, "SIGCONT");
|
||||
}
|
||||
throw new Error(`No isolated terminal worker owns ${terminalId}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'expo-module-gradle-plugin'
|
||||
|
||||
group = 'sh.paseo.keyboard'
|
||||
version = '0.1.0'
|
||||
android {
|
||||
namespace 'sh.paseo.keyboard'
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName '0.1.0'
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation 'com.facebook.react:react-android'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.view.KeyCharacterMap
|
||||
|
||||
internal fun hardwareCharacterKey(character: Int): String =
|
||||
if (character and KeyCharacterMap.COMBINING_ACCENT != 0) "Dead"
|
||||
else String(Character.toChars(character))
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.content.ComponentCallbacks
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.Window
|
||||
import android.widget.EditText
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.interfaces.ExtraWindowEventListener
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
internal object HardwareKeyboard {
|
||||
var module: PaseoHardwareKeyboardModule? = null
|
||||
@Volatile var overlayKeys: Set<String> = emptySet()
|
||||
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
|
||||
val dispatcher = WindowKeyDispatcher<KeyEvent>()
|
||||
private val consumedPresses = mutableSetOf<Int>()
|
||||
|
||||
fun observe(window: Window) {
|
||||
if (windows.containsKey(window)) return
|
||||
val callback = KeyboardWindowCallback(window, window.callback)
|
||||
windows[window] = callback
|
||||
window.callback = callback
|
||||
}
|
||||
fun release(window: Window) {
|
||||
val callback = windows.remove(window) ?: return
|
||||
if (window.callback === callback) window.callback = callback.previous
|
||||
}
|
||||
fun releaseAll() {
|
||||
windows.keys.toList().forEach { release(it) }
|
||||
consumedPresses.clear()
|
||||
}
|
||||
|
||||
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
|
||||
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
|
||||
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
|
||||
val code = code(event.keyCode)
|
||||
if (code.isEmpty()) return false
|
||||
val textModifiers = event.metaState and KeyEvent.META_CTRL_MASK.inv() and KeyEvent.META_META_MASK.inv()
|
||||
val character = event.getUnicodeChar(textModifiers)
|
||||
val named = !code.startsWith("Key") && !code.startsWith("Digit")
|
||||
val key = when {
|
||||
code.startsWith("Control") -> "Control"
|
||||
code.startsWith("Meta") -> "Meta"
|
||||
code.startsWith("Shift") -> "Shift"
|
||||
code.startsWith("Alt") -> "Alt"
|
||||
named && (character == 0 || code == "Enter" || code == "Tab" || code == "Backspace") -> code
|
||||
character != 0 -> hardwareCharacterKey(character)
|
||||
else -> code
|
||||
}
|
||||
module?.emit(mapOf(
|
||||
"key" to key, "code" to code,
|
||||
"altKey" to event.isAltPressed, "ctrlKey" to event.isCtrlPressed,
|
||||
"metaKey" to event.isMetaPressed, "shiftKey" to event.isShiftPressed,
|
||||
"repeat" to (event.repeatCount > 0), "isComposing" to composing,
|
||||
"phase" to if (event.action == KeyEvent.ACTION_UP) "up" else "down"
|
||||
))
|
||||
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
|
||||
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
|
||||
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
|
||||
return overlayOwnsKey
|
||||
}
|
||||
|
||||
private fun code(keyCode: Int): String = when (keyCode) {
|
||||
in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> "Key" + ('A'.code + keyCode - KeyEvent.KEYCODE_A).toChar()
|
||||
in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> "Digit" + (keyCode - KeyEvent.KEYCODE_0)
|
||||
in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 -> "F" + (keyCode - KeyEvent.KEYCODE_F1 + 1)
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> "Enter"
|
||||
KeyEvent.KEYCODE_ESCAPE -> "Escape"
|
||||
KeyEvent.KEYCODE_DEL -> "Backspace"
|
||||
KeyEvent.KEYCODE_FORWARD_DEL -> "Delete"
|
||||
KeyEvent.KEYCODE_TAB -> "Tab"
|
||||
KeyEvent.KEYCODE_SPACE -> "Space"
|
||||
KeyEvent.KEYCODE_DPAD_UP -> "ArrowUp"
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> "ArrowDown"
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> "ArrowLeft"
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> "ArrowRight"
|
||||
KeyEvent.KEYCODE_CTRL_LEFT -> "ControlLeft"
|
||||
KeyEvent.KEYCODE_CTRL_RIGHT -> "ControlRight"
|
||||
KeyEvent.KEYCODE_META_LEFT -> "MetaLeft"
|
||||
KeyEvent.KEYCODE_META_RIGHT -> "MetaRight"
|
||||
KeyEvent.KEYCODE_SHIFT_LEFT -> "ShiftLeft"
|
||||
KeyEvent.KEYCODE_SHIFT_RIGHT -> "ShiftRight"
|
||||
KeyEvent.KEYCODE_ALT_LEFT -> "AltLeft"
|
||||
KeyEvent.KEYCODE_ALT_RIGHT -> "AltRight"
|
||||
KeyEvent.KEYCODE_COMMA -> "Comma"
|
||||
KeyEvent.KEYCODE_PERIOD -> "Period"
|
||||
KeyEvent.KEYCODE_MINUS -> "Minus"
|
||||
KeyEvent.KEYCODE_EQUALS -> "Equal"
|
||||
KeyEvent.KEYCODE_LEFT_BRACKET -> "BracketLeft"
|
||||
KeyEvent.KEYCODE_RIGHT_BRACKET -> "BracketRight"
|
||||
KeyEvent.KEYCODE_BACKSLASH -> "Backslash"
|
||||
KeyEvent.KEYCODE_SEMICOLON -> "Semicolon"
|
||||
KeyEvent.KEYCODE_APOSTROPHE -> "Quote"
|
||||
KeyEvent.KEYCODE_GRAVE -> "Backquote"
|
||||
KeyEvent.KEYCODE_SLASH -> "Slash"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val editable = (window.currentFocus as? EditText)?.text
|
||||
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
|
||||
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
|
||||
}
|
||||
}
|
||||
|
||||
class PaseoHardwareKeyboardModule : Module(), ExtraWindowEventListener, InputManager.InputDeviceListener, ComponentCallbacks {
|
||||
private var inputManager: InputManager? = null
|
||||
private var registeredReactContext: ReactContext? = null
|
||||
private fun keyboardPresent(): Boolean {
|
||||
val config = registeredReactContext?.resources?.configuration
|
||||
return (config?.keyboard == Configuration.KEYBOARD_QWERTY && config.hardKeyboardHidden != Configuration.HARDKEYBOARDHIDDEN_YES) ||
|
||||
InputDevice.getDeviceIds().any { id -> InputDevice.getDevice(id)?.let { !it.isVirtual && it.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC } == true }
|
||||
}
|
||||
private fun presenceChanged() { sendEvent("onHardwareKeyboardPresenceChanged", emptyMap<String, Any>()) }
|
||||
override fun onInputDeviceAdded(id: Int) { presenceChanged() }
|
||||
override fun onInputDeviceRemoved(id: Int) { presenceChanged() }
|
||||
override fun onInputDeviceChanged(id: Int) { presenceChanged() }
|
||||
override fun onConfigurationChanged(config: Configuration) { presenceChanged() }
|
||||
override fun onLowMemory() {}
|
||||
override fun onExtraWindowCreate(window: Window) { HardwareKeyboard.observe(window) }
|
||||
override fun onExtraWindowDestroy(window: Window) { HardwareKeyboard.release(window) }
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("PaseoHardwareKeyboard")
|
||||
Events("onHardwareKeyboardShortcut", "onHardwareKeyboardPresenceChanged")
|
||||
OnCreate {
|
||||
HardwareKeyboard.module = this@PaseoHardwareKeyboardModule
|
||||
val context = appContext.reactContext
|
||||
require(context is ReactContext) { "Hardware keyboard observation requires a React context" }
|
||||
registeredReactContext = context
|
||||
context.registerComponentCallbacks(this@PaseoHardwareKeyboardModule)
|
||||
inputManager = context.getSystemService(InputManager::class.java)
|
||||
inputManager?.registerInputDeviceListener(this@PaseoHardwareKeyboardModule, Handler(Looper.getMainLooper()))
|
||||
context.addExtraWindowEventListener(this@PaseoHardwareKeyboardModule)
|
||||
UiThreadUtil.runOnUiThread { appContext.currentActivity?.window?.let { HardwareKeyboard.observe(it) } }
|
||||
}
|
||||
OnDestroy {
|
||||
inputManager?.unregisterInputDeviceListener(this@PaseoHardwareKeyboardModule)
|
||||
inputManager = null
|
||||
registeredReactContext?.unregisterComponentCallbacks(this@PaseoHardwareKeyboardModule)
|
||||
registeredReactContext?.removeExtraWindowEventListener(this@PaseoHardwareKeyboardModule)
|
||||
registeredReactContext = null
|
||||
if (HardwareKeyboard.module === this@PaseoHardwareKeyboardModule) {
|
||||
HardwareKeyboard.module = null
|
||||
HardwareKeyboard.overlayKeys = emptySet()
|
||||
UiThreadUtil.runOnUiThread { if (HardwareKeyboard.module == null) HardwareKeyboard.releaseAll() }
|
||||
}
|
||||
}
|
||||
Function("getHardwareKeyboardPresent") { keyboardPresent() }
|
||||
Function("setOverlayKeys") { keys: List<String> ->
|
||||
HardwareKeyboard.overlayKeys = keys.filter { it in setOf("Escape", "Enter", "ArrowUp", "ArrowDown") }.toSet()
|
||||
}
|
||||
}
|
||||
internal fun emit(event: Map<String, Any>) { sendEvent("onHardwareKeyboardShortcut", event) }
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import expo.modules.core.interfaces.Package
|
||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||
|
||||
class PaseoHardwareKeyboardPackage : Package {
|
||||
override fun createReactActivityLifecycleListeners(activityContext: Context): List<ReactActivityLifecycleListener> = listOf(
|
||||
object : ReactActivityLifecycleListener {
|
||||
override fun onCreate(activity: Activity, savedInstanceState: Bundle?) { HardwareKeyboard.observe(activity.window) }
|
||||
override fun onResume(activity: Activity) { HardwareKeyboard.observe(activity.window) }
|
||||
override fun onDestroy(activity: Activity) { HardwareKeyboard.release(activity.window) }
|
||||
}
|
||||
)
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
// A modal can forward the same physical event into the activity window. Keep
|
||||
// observation outside that nested delivery, without retaining recycled KeyEvents.
|
||||
internal class WindowKeyDispatcher<Event : Any> {
|
||||
private val delivering = java.util.Collections.newSetFromMap(java.util.IdentityHashMap<Event, Boolean>())
|
||||
fun dispatch(event: Event, observe: () -> Boolean, deliver: () -> Boolean): Boolean {
|
||||
if (!delivering.add(event)) return deliver()
|
||||
return try { observe() || deliver() } finally { delivering.remove(event) }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package sh.paseo.keyboard
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
class HardwareCharacterKeyTest {
|
||||
@Test fun deadAccentUsesDomKeyWithoutThrowing() {
|
||||
assertEquals("Dead", hardwareCharacterKey(Int.MIN_VALUE or 0xB4))
|
||||
}
|
||||
@Test fun ordinaryUnicodeRemainsText() {
|
||||
assertEquals("é", hardwareCharacterKey(0xE9))
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WindowKeyDispatcherTest {
|
||||
@Test fun observesOnePressAcrossNestedWindows() {
|
||||
val dispatcher = WindowKeyDispatcher<Any>()
|
||||
val event = Any()
|
||||
var observations = 0
|
||||
var deliveries = 0
|
||||
dispatcher.dispatch(event, { observations++; false }, {
|
||||
dispatcher.dispatch(event, { observations++; false }, { deliveries++; false })
|
||||
})
|
||||
assertEquals(1, observations)
|
||||
assertEquals(1, deliveries)
|
||||
dispatcher.dispatch(event, { observations++; false }, { false })
|
||||
assertEquals(2, observations)
|
||||
}
|
||||
@Test fun consumedOverlayKeyDoesNotReachTheView() {
|
||||
val dispatcher = WindowKeyDispatcher<Any>()
|
||||
var deliveries = 0
|
||||
assertTrue(dispatcher.dispatch(Any(), { true }, { deliveries++; false }))
|
||||
assertEquals(0, deliveries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
const { withAppDelegate } = require("expo/config-plugins");
|
||||
const SCENE_START = "super.scene(scene, willConnectTo: session, options: connectionOptions)";
|
||||
|
||||
function configureHardwareKeyboardAppDelegate(source) {
|
||||
source = source.replace(
|
||||
/^import PaseoHardwareKeyboard$/m,
|
||||
"internal import PaseoHardwareKeyboard",
|
||||
);
|
||||
if (source.includes("PaseoHardwareKeyboardWindow.observing(window)")) return source;
|
||||
if (!source.includes(SCENE_START)) {
|
||||
throw new Error("Hardware keyboard observation requires the app's Expo scene delegate");
|
||||
}
|
||||
const installation = `${SCENE_START}
|
||||
if let window {
|
||||
let observedWindow = PaseoHardwareKeyboardWindow.observing(window)
|
||||
self.window = observedWindow
|
||||
(UIApplication.shared.delegate as? ExpoReactNativeFactoryProvider)?.window = observedWindow
|
||||
}`;
|
||||
return "internal import PaseoHardwareKeyboard\n" + source.replace(SCENE_START, installation);
|
||||
}
|
||||
module.exports = function withHardwareKeyboardWindow(config) {
|
||||
return withAppDelegate(config, (modConfig) => {
|
||||
modConfig.modResults.contents = configureHardwareKeyboardAppDelegate(
|
||||
modConfig.modResults.contents,
|
||||
);
|
||||
return modConfig;
|
||||
});
|
||||
};
|
||||
module.exports.configureHardwareKeyboardAppDelegate = configureHardwareKeyboardAppDelegate;
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"platforms": ["ios", "android"],
|
||||
"ios": {
|
||||
"modules": ["PaseoHardwareKeyboardModule"],
|
||||
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
|
||||
"modules": ["PaseoHardwareKeyboardModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["sh.paseo.keyboard.PaseoHardwareKeyboardModule"]
|
||||
}
|
||||
}
|
||||
|
||||
+150
-12
@@ -1,53 +1,159 @@
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
import GameController
|
||||
|
||||
private let hardwareSubmitEventName = "onHardwareKeyboardSubmit"
|
||||
private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
|
||||
|
||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||
private var isHardwareSubmitEnabled = false
|
||||
private var overlayKeys: Set<String> = []
|
||||
|
||||
@objc
|
||||
public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler {
|
||||
public override func createRootViewController() -> UIViewController? {
|
||||
return PaseoHardwareKeyboardRootViewController()
|
||||
/// UIKit spells non-character keys as sentinel input strings rather than as the
|
||||
/// character they would type. JS sends the DOM `code` and the mapping lives
|
||||
/// here, so the constants stay in the one place that owns UIKit vocabulary.
|
||||
private let namedKeyInputs: [String: String] = [
|
||||
"Escape": UIKeyCommand.inputEscape,
|
||||
"ArrowUp": UIKeyCommand.inputUpArrow,
|
||||
"ArrowDown": UIKeyCommand.inputDownArrow,
|
||||
"Enter": "\r",
|
||||
]
|
||||
|
||||
/// Whether the first responder is mid-IME-composition.
|
||||
///
|
||||
/// A committing key has to stay out of the way while a CJK candidate is being
|
||||
/// chosen: Enter confirms the candidate, and consuming it as a key command
|
||||
/// would submit the half-composed text instead.
|
||||
private func isComposingText() -> Bool {
|
||||
guard let responder = UIResponder.paseoCurrentFirstResponder as? UITextInput else {
|
||||
return false
|
||||
}
|
||||
return responder.markedTextRange != nil
|
||||
}
|
||||
|
||||
public class PaseoHardwareKeyboardModule: Module {
|
||||
private var keyboardObservers: [NSObjectProtocol] = []
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("PaseoHardwareKeyboard")
|
||||
|
||||
Events(hardwareSubmitEventName)
|
||||
Events(hardwareSubmitEventName, hardwareShortcutEventName, "onHardwareKeyboardPresenceChanged")
|
||||
|
||||
OnCreate {
|
||||
activeModule = self
|
||||
for name in [Notification.Name.GCKeyboardDidConnect, Notification.Name.GCKeyboardDidDisconnect] {
|
||||
self.keyboardObservers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
self?.sendEvent("onHardwareKeyboardPresenceChanged", [:])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Function("getHardwareKeyboardPresent") { GCKeyboard.coalesced != nil }
|
||||
|
||||
Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in
|
||||
DispatchQueue.main.async {
|
||||
isHardwareSubmitEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
Function("setOverlayKeys") { (keys: [String]) in
|
||||
DispatchQueue.main.async {
|
||||
overlayKeys = Set(keys).intersection(["Enter", "ArrowUp", "ArrowDown"])
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
}
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
|
||||
self.keyboardObservers = []
|
||||
if activeModule === self {
|
||||
activeModule = nil
|
||||
isHardwareSubmitEnabled = false
|
||||
overlayKeys = []
|
||||
}
|
||||
isHardwareSubmitEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func emitHardwareKeyboardSubmit() {
|
||||
sendEvent(hardwareSubmitEventName, [:])
|
||||
}
|
||||
|
||||
fileprivate func emitHardwareKeyboardPress(_ press: UIPress, isRepeat: Bool) {
|
||||
guard let key = press.key else { return }
|
||||
let code = hardwareKeyCode(key)
|
||||
let characters = key.characters
|
||||
let named = namedKeyInputs.first { $0.value == characters }?.key
|
||||
let keyName: String
|
||||
if code.hasPrefix("Meta") { keyName = "Meta" }
|
||||
else if code.hasPrefix("Control") { keyName = "Control" }
|
||||
else if code.hasPrefix("Shift") { keyName = "Shift" }
|
||||
else if code.hasPrefix("Alt") { keyName = "Alt" }
|
||||
else { keyName = named ?? (characters.isEmpty ? code : characters) }
|
||||
guard !code.isEmpty else { return }
|
||||
sendEvent(hardwareShortcutEventName, [
|
||||
"key": keyName,
|
||||
"code": code,
|
||||
"altKey": key.modifierFlags.contains(.alternate),
|
||||
"ctrlKey": key.modifierFlags.contains(.control),
|
||||
"metaKey": key.modifierFlags.contains(.command),
|
||||
"shiftKey": key.modifierFlags.contains(.shift),
|
||||
"repeat": isRepeat,
|
||||
"isComposing": isComposingText(),
|
||||
"phase": press.phase == .ended || press.phase == .cancelled ? "up" : "down",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else {
|
||||
return super.keyCommands
|
||||
}
|
||||
public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
private var pressedCodes: Set<Int> = []
|
||||
public static func observing(_ original: UIWindow) -> UIWindow {
|
||||
if original is PaseoHardwareKeyboardWindow { return original }
|
||||
guard let scene = original.windowScene else { return original }
|
||||
let observed = PaseoHardwareKeyboardWindow(windowScene: scene)
|
||||
observed.windowLevel = original.windowLevel
|
||||
observed.backgroundColor = original.backgroundColor
|
||||
let controller = original.rootViewController
|
||||
original.rootViewController = nil
|
||||
original.isHidden = true
|
||||
observed.rootViewController = controller
|
||||
observed.makeKeyAndVisible()
|
||||
return observed
|
||||
}
|
||||
|
||||
public override func sendEvent(_ event: UIEvent) {
|
||||
if let event = event as? UIPressesEvent {
|
||||
for press in event.allPresses where press.phase == .began || press.phase == .ended || press.phase == .cancelled {
|
||||
guard let key = press.key else { continue }
|
||||
let raw = Int(key.keyCode.rawValue)
|
||||
let isRepeat = press.phase == .began && pressedCodes.contains(raw)
|
||||
if press.phase == .began { pressedCodes.insert(raw) }
|
||||
else { pressedCodes.remove(raw) }
|
||||
activeModule?.emitHardwareKeyboardPress(press, isRepeat: isRepeat)
|
||||
}
|
||||
}
|
||||
super.sendEvent(event)
|
||||
}
|
||||
public override var keyCommands: [UIKeyCommand]? {
|
||||
var commands = super.keyCommands ?? []
|
||||
if isHardwareSubmitEnabled && !isPlainReturnClaimed && !isComposingText()
|
||||
&& UIDevice.current.userInterfaceIdiom == .pad
|
||||
{
|
||||
commands.append(makeSubmitCommand())
|
||||
}
|
||||
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
|
||||
return commands
|
||||
}
|
||||
|
||||
/// Whether something registered has already asked for an unmodified Return.
|
||||
///
|
||||
/// The submit command is registered for as long as the composer holds focus,
|
||||
/// which includes while a picker is open over it. Registering both would put
|
||||
/// two commands on one press, and the composer would win a press the picker
|
||||
/// asked for — Enter would create the workspace instead of choosing a project.
|
||||
private var isPlainReturnClaimed: Bool {
|
||||
overlayKeys.contains("Enter")
|
||||
}
|
||||
|
||||
private func makeSubmitCommand() -> UIKeyCommand {
|
||||
let command = UIKeyCommand(
|
||||
input: "\r",
|
||||
modifierFlags: [],
|
||||
@@ -56,7 +162,14 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
||||
if #available(iOS 15.0, *) {
|
||||
command.wantsPriorityOverSystemBehavior = true
|
||||
}
|
||||
return (super.keyCommands ?? []) + [command]
|
||||
return command
|
||||
}
|
||||
|
||||
private func makeShortcutCommand(_ key: String) -> UIKeyCommand? {
|
||||
guard let input = namedKeyInputs[key] else { return nil }
|
||||
let command = UIKeyCommand(input: input, modifierFlags: [], action: #selector(handleHardwareKeyboardShortcut(_:)))
|
||||
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
|
||||
return command
|
||||
}
|
||||
|
||||
@objc
|
||||
@@ -67,6 +180,9 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
||||
activeModule?.emitHardwareKeyboardSubmit()
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleHardwareKeyboardShortcut(_ sender: UIKeyCommand) {}
|
||||
|
||||
private func canSubmitCurrentTextInput() -> Bool {
|
||||
guard let responder = UIResponder.paseoCurrentFirstResponder else {
|
||||
return false
|
||||
@@ -97,3 +213,25 @@ private extension UIResponder {
|
||||
UIResponder.currentFirstResponder = self
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func hardwareKeyCode(_ key: UIKey) -> String {
|
||||
let raw = key.keyCode.rawValue
|
||||
if raw >= 4 && raw <= 29 {
|
||||
let letter = UnicodeScalar(Int(raw) + 61)!
|
||||
return "Key" + String(letter)
|
||||
}
|
||||
if raw >= 30 && raw <= 38 { return "Digit" + String(raw - 29) }
|
||||
if raw == 39 { return "Digit0" }
|
||||
let codes: [Int: String] = [
|
||||
40: "Enter", 41: "Escape", 42: "Backspace", 43: "Tab", 44: "Space",
|
||||
45: "Minus", 46: "Equal", 47: "BracketLeft", 48: "BracketRight",
|
||||
49: "Backslash", 51: "Semicolon", 52: "Quote", 53: "Backquote",
|
||||
54: "Comma", 55: "Period", 56: "Slash", 76: "Delete", 79: "ArrowRight",
|
||||
80: "ArrowLeft", 81: "ArrowDown", 82: "ArrowUp", 224: "ControlLeft",
|
||||
225: "ShiftLeft", 226: "AltLeft", 227: "MetaLeft", 228: "ControlRight",
|
||||
229: "ShiftRight", 230: "AltRight", 231: "MetaRight",
|
||||
]
|
||||
if raw >= 58 && raw <= 69 { return "F" + String(raw - 57) }
|
||||
return codes[Int(raw)] ?? ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!-- Hosts a scroll view in a real window, so layout clamps and draws run as they do on device. -->
|
||||
<activity android:name="android.app.Activity" android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package sh.paseo.scroll;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.react.bridge.BridgeReactContext;
|
||||
import com.facebook.react.bridge.UIManager;
|
||||
import com.facebook.react.bridge.UIManagerListener;
|
||||
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcherProvider;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A React context with a running Fabric UIManager, so scroll views find the mount listener
|
||||
* registry and event dispatcher the way they do in the app. The UIManager only records mount
|
||||
* listeners and the dispatcher drops events; every other call returns a default. Java, because
|
||||
* {@link EventDispatcherProvider} is internal to React Native's Kotlin module.
|
||||
*/
|
||||
final class MountingReactContext extends BridgeReactContext implements EventDispatcherProvider {
|
||||
final List<UIManagerListener> mountListeners = new ArrayList<>();
|
||||
final UIManager uiManager;
|
||||
private final EventDispatcher eventDispatcher = inert(EventDispatcher.class, null);
|
||||
|
||||
MountingReactContext(Context base) {
|
||||
super(base);
|
||||
uiManager = inert(UIManager.class, this);
|
||||
}
|
||||
|
||||
/** Runs one Fabric mount: listeners see willMountItems, then the change, then didMountItems. */
|
||||
void mount(Runnable change) {
|
||||
for (UIManagerListener listener : new ArrayList<>(mountListeners)) listener.willMountItems(uiManager);
|
||||
change.run();
|
||||
for (UIManagerListener listener : new ArrayList<>(mountListeners)) listener.didMountItems(uiManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasActiveReactInstance() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UIManager getFabricUIManager() {
|
||||
return uiManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventDispatcher getEventDispatcher() {
|
||||
return eventDispatcher;
|
||||
}
|
||||
|
||||
private static <T> T inert(Class<T> type, MountingReactContext owner) {
|
||||
return type.cast(
|
||||
Proxy.newProxyInstance(
|
||||
type.getClassLoader(),
|
||||
new Class<?>[] {type},
|
||||
(proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "addUIManagerEventListener":
|
||||
owner.mountListeners.add((UIManagerListener) args[0]);
|
||||
return null;
|
||||
case "removeUIManagerEventListener":
|
||||
owner.mountListeners.remove((UIManagerListener) args[0]);
|
||||
return null;
|
||||
case "getEventDispatcher":
|
||||
return owner.eventDispatcher;
|
||||
default:
|
||||
Class<?> result = method.getReturnType();
|
||||
if (result == boolean.class) return false;
|
||||
if (result == int.class) return 0;
|
||||
if (result == long.class) return 0L;
|
||||
if (result == float.class) return 0f;
|
||||
if (result == double.class) return 0.0;
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
package sh.paseo.scroll
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.text.Selection
|
||||
import android.text.Spannable
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
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
|
||||
import com.facebook.soloader.SoLoader
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ScrollSelectionTest {
|
||||
@Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
|
||||
val before = scroll.scrollY
|
||||
assertTrue(text.requestFocus())
|
||||
assertSame(text, scroll.findFocus())
|
||||
assertEquals("Text focus must not move the inverted viewport", before, scroll.scrollY)
|
||||
assertTrue(text.isTextSelectable)
|
||||
}
|
||||
|
||||
@Test fun selectingTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
|
||||
val before = scroll.scrollY
|
||||
Selection.setSelection(text.text as Spannable, 0, 8)
|
||||
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
|
||||
assertEquals("Selection visibility requests must not move the inverted viewport", before, scroll.scrollY)
|
||||
assertEquals("Selectab", text.text.subSequence(text.selectionStart, text.selectionEnd).toString())
|
||||
}
|
||||
|
||||
@Test fun explicitScrollCommandsStillMoveInvertedViewport() = withScroll(inverted = true) { scroll, _ ->
|
||||
scroll.scrollTo(0, 600)
|
||||
assertEquals(600, scroll.scrollY)
|
||||
}
|
||||
|
||||
@Test fun ordinaryScrollViewsStillRevealSelectedText() = withScroll(inverted = false) { scroll, text ->
|
||||
val before = scroll.scrollY
|
||||
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
|
||||
assertNotEquals(before, scroll.scrollY)
|
||||
}
|
||||
|
||||
@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)
|
||||
val content = FrameLayout(context)
|
||||
val text = TextView(context).apply {
|
||||
setText("Selectable timeline text", TextView.BufferType.SPANNABLE)
|
||||
setTextIsSelectable(true)
|
||||
}
|
||||
content.addView(text, FrameLayout.LayoutParams(300, 100).apply { topMargin = 1400 })
|
||||
scroll.addView(content)
|
||||
content.measure(exact(300), exact(2000))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
package sh.paseo.scroll
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Canvas
|
||||
import android.os.SystemClock
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
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.RootView
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.views.scroll.ReactScrollView
|
||||
import com.facebook.react.views.scroll.ReactScrollViewManager
|
||||
import com.facebook.react.views.view.ReactViewGroup
|
||||
import com.facebook.soloader.SoLoader
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* A Fabric mount that shrinks the content of a scroll view with `maintainVisibleContentPosition`
|
||||
* set. The mount runs through React Native's real position keeper, driven by the same
|
||||
* willMountItems/didMountItems calls Fabric makes, inside a real window so layout clamps and draws
|
||||
* happen as they do on a device.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class VisibleContentPositionClampTest {
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private var activity: Activity? = null
|
||||
|
||||
@After fun finish() {
|
||||
activity?.finish()
|
||||
}
|
||||
|
||||
@Test fun shrinkAtTheEndLandsAtTheNewEnd() {
|
||||
val timeline = openTimeline(anchor = Anchor.END)
|
||||
|
||||
timeline.shrinkInOneMount()
|
||||
|
||||
assertEquals("Offset after the mount", NEW_END, timeline.scrollY())
|
||||
assertEquals("Offset in the first frame drawn after the mount", NEW_END, timeline.firstDrawnScrollY())
|
||||
}
|
||||
|
||||
@Test fun shrinkBelowAnUnmovedAnchorIsClampedBeforeTheFrameDraws() {
|
||||
val timeline = openTimeline(anchor = Anchor.TOP_ROW)
|
||||
|
||||
timeline.shrinkInOneMount()
|
||||
|
||||
assertEquals("Offset in the first frame drawn after the mount", NEW_END, timeline.firstDrawnScrollY())
|
||||
assertEquals("Offset after the mount", NEW_END, timeline.scrollY())
|
||||
}
|
||||
|
||||
@Test fun shrinkDuringATouchDragLeavesNoOffsetPastTheEnd() {
|
||||
val timeline = openTimeline(anchor = Anchor.TOP_ROW)
|
||||
|
||||
timeline.beginDrag()
|
||||
assertTrue("The drag owns the viewport", timeline.scrollY() < CONTENT_HEIGHT - VIEWPORT)
|
||||
timeline.shrinkInOneMount()
|
||||
val drawn = timeline.firstDrawnScrollY()
|
||||
timeline.endDrag()
|
||||
val settled = timeline.settledScrollY()
|
||||
|
||||
assertTrue("Offset $drawn drawn past the end $NEW_END", drawn <= NEW_END)
|
||||
assertTrue("Offset $settled settled past the end $NEW_END", settled <= NEW_END)
|
||||
timeline.awaitFrames(10)
|
||||
assertEquals("Offset kept moving after the drag ended", settled, timeline.scrollY())
|
||||
}
|
||||
|
||||
@Test fun growthAtTheEndFollowsWithoutATouch() {
|
||||
val timeline = openTimeline(anchor = Anchor.END)
|
||||
|
||||
timeline.growInOneMount()
|
||||
|
||||
assertEquals("Offset after the mount", CONTENT_HEIGHT + GROWTH - VIEWPORT, timeline.scrollY())
|
||||
}
|
||||
|
||||
// The drag begins before JS stops following and removes the pin, so a line mounting in the drag's
|
||||
// first frames must not scroll the rows the finger is on.
|
||||
@Test fun growthAtTheEndDuringATouchDragKeepsTheRowsUnderTheFinger() {
|
||||
val timeline = openTimeline(anchor = Anchor.END)
|
||||
|
||||
timeline.beginDrag()
|
||||
val dragged = timeline.scrollY()
|
||||
assertTrue("The drag owns the viewport", dragged < CONTENT_HEIGHT - VIEWPORT)
|
||||
timeline.growInOneMount()
|
||||
|
||||
assertEquals("Offset after the mount", dragged, timeline.scrollY())
|
||||
assertEquals("Offset in the first frame drawn after the mount", dragged, timeline.firstDrawnScrollY())
|
||||
timeline.endDrag()
|
||||
}
|
||||
|
||||
private enum class Anchor {
|
||||
/** A zero-height view at the bottom of the content, first child: the end pin. */
|
||||
END,
|
||||
/** A row at the top of the content that reaches into the viewport and does not move. */
|
||||
TOP_ROW,
|
||||
}
|
||||
|
||||
private inner class Timeline(
|
||||
val scroll: ReactScrollView,
|
||||
private val content: ReactViewGroup,
|
||||
private val endPin: View,
|
||||
private val probe: DrawProbe,
|
||||
private val reactContext: MountingReactContext,
|
||||
) {
|
||||
private var dragY = 0f
|
||||
private var downTime = 0L
|
||||
|
||||
fun scrollY(): Int {
|
||||
var y = 0
|
||||
instrumentation.runOnMainSync { y = scroll.scrollY }
|
||||
return y
|
||||
}
|
||||
|
||||
fun shrinkInOneMount() = resizeInOneMount(CONTENT_HEIGHT - SHRINK)
|
||||
|
||||
fun growInOneMount() = resizeInOneMount(CONTENT_HEIGHT + GROWTH)
|
||||
|
||||
private fun resizeInOneMount(height: Int) {
|
||||
instrumentation.runOnMainSync {
|
||||
probe.drawnScrollY.clear()
|
||||
reactContext.mount { layoutContent(height) }
|
||||
probe.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
fun firstDrawnScrollY(): Int {
|
||||
val deadline = SystemClock.uptimeMillis() + 2000
|
||||
while (SystemClock.uptimeMillis() < deadline) {
|
||||
instrumentation.waitForIdleSync()
|
||||
var drawn: Int? = null
|
||||
instrumentation.runOnMainSync { drawn = probe.drawnScrollY.firstOrNull() }
|
||||
drawn?.let { return it }
|
||||
SystemClock.sleep(16)
|
||||
}
|
||||
throw AssertionError("No frame was drawn after the mount")
|
||||
}
|
||||
|
||||
fun beginDrag() {
|
||||
downTime = SystemClock.uptimeMillis()
|
||||
dragY = 300f
|
||||
touch(MotionEvent.ACTION_DOWN)
|
||||
// Finger moves down, so the content scrolls up from the end and the drag owns the viewport.
|
||||
repeat(6) {
|
||||
dragY += 12f
|
||||
touch(MotionEvent.ACTION_MOVE)
|
||||
}
|
||||
}
|
||||
|
||||
fun endDrag() {
|
||||
dragY += 4f
|
||||
touch(MotionEvent.ACTION_MOVE)
|
||||
touch(MotionEvent.ACTION_UP)
|
||||
}
|
||||
|
||||
fun settledScrollY(): Int {
|
||||
var previous = scrollY()
|
||||
repeat(60) {
|
||||
awaitFrames(2)
|
||||
val current = scrollY()
|
||||
if (current == previous) return current
|
||||
previous = current
|
||||
}
|
||||
throw AssertionError("Offset never settled")
|
||||
}
|
||||
|
||||
fun awaitFrames(count: Int) {
|
||||
repeat(count) {
|
||||
instrumentation.waitForIdleSync()
|
||||
SystemClock.sleep(16)
|
||||
}
|
||||
}
|
||||
|
||||
fun layoutContent(height: Int) {
|
||||
content.layout(0, 0, WIDTH, height)
|
||||
endPin.layout(0, height, 0, height)
|
||||
}
|
||||
|
||||
private fun touch(action: Int) {
|
||||
instrumentation.runOnMainSync {
|
||||
val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, WIDTH / 2f, dragY, 0)
|
||||
scroll.dispatchTouchEvent(event)
|
||||
event.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The React surface root a scroll view reports its native gestures to. */
|
||||
private class SurfaceRoot(context: Context) : FrameLayout(context), RootView {
|
||||
override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) {}
|
||||
|
||||
override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) {}
|
||||
|
||||
override fun handleException(t: Throwable) {
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private class DrawProbe(context: Context) : View(context) {
|
||||
val drawnScrollY = mutableListOf<Int>()
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawnScrollY += (parent.parent as View).scrollY
|
||||
}
|
||||
}
|
||||
|
||||
private fun openTimeline(anchor: Anchor): Timeline {
|
||||
val started =
|
||||
instrumentation.startActivitySync(
|
||||
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
activity = started
|
||||
lateinit var timeline: Timeline
|
||||
instrumentation.runOnMainSync {
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
|
||||
SoLoader.init(started, OpenSourceMergedSoMapping)
|
||||
val reactContext = MountingReactContext(started.applicationContext)
|
||||
val context = ThemedReactContext(reactContext, started)
|
||||
val manager =
|
||||
PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS)
|
||||
as ReactScrollViewManager
|
||||
val scroll = manager.createViewInstance(context)
|
||||
val content = ReactViewGroup(context)
|
||||
val endPin = View(context).apply { setTag(com.facebook.react.R.id.view_tag_native_id, END_PIN_NATIVE_ID) }
|
||||
val probe = DrawProbe(context)
|
||||
if (anchor == Anchor.END) {
|
||||
content.addView(endPin)
|
||||
content.addView(probe)
|
||||
} else {
|
||||
content.addView(probe)
|
||||
content.addView(endPin)
|
||||
}
|
||||
scroll.addView(content)
|
||||
manager.updateProperties(
|
||||
scroll,
|
||||
ReactStylesDiffMap(
|
||||
JavaOnlyMap.of("maintainVisibleContentPosition", JavaOnlyMap.of("minIndexForVisible", 0))
|
||||
),
|
||||
)
|
||||
started.setContentView(SurfaceRoot(started).apply { addView(scroll, FrameLayout.LayoutParams(WIDTH, VIEWPORT)) })
|
||||
timeline = Timeline(scroll, content, endPin, probe, reactContext)
|
||||
timeline.layoutContent(CONTENT_HEIGHT)
|
||||
probe.layout(0, 0, WIDTH, PROBE_HEIGHT)
|
||||
}
|
||||
timeline.awaitFrames(2)
|
||||
instrumentation.runOnMainSync { timeline.scroll.scrollTo(0, CONTENT_HEIGHT - VIEWPORT) }
|
||||
timeline.awaitFrames(2)
|
||||
assertEquals("Starts at the end", CONTENT_HEIGHT - VIEWPORT, timeline.scrollY())
|
||||
return timeline
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val WIDTH = 300
|
||||
const val VIEWPORT = 600
|
||||
const val CONTENT_HEIGHT = 1000
|
||||
const val SHRINK = 240
|
||||
const val GROWTH = 120
|
||||
const val END_PIN_NATIVE_ID = "paseo-end-pin"
|
||||
const val NEW_END = CONTENT_HEIGHT - SHRINK - VIEWPORT
|
||||
const val PROBE_HEIGHT = 700
|
||||
}
|
||||
}
|
||||
-16
@@ -1,7 +1,5 @@
|
||||
package sh.paseo.scroll
|
||||
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Rect
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ViewManagerOnDemandReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
@@ -26,17 +24,3 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
|
||||
override fun createViewInstance(context: ThemedReactContext): ReactScrollView =
|
||||
PaseoScrollView(context)
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package sh.paseo.scroll;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import androidx.annotation.Nullable;
|
||||
import com.facebook.react.R;
|
||||
import com.facebook.react.views.scroll.MaintainVisibleScrollPositionHelper;
|
||||
import com.facebook.react.views.scroll.ReactScrollView;
|
||||
import com.facebook.react.views.scroll.ReactScrollViewHelper;
|
||||
|
||||
/**
|
||||
* React Native's vertical scroll view with two corrections. Java, because the content-position
|
||||
* config type is internal to React Native's Kotlin module.
|
||||
*/
|
||||
final class PaseoScrollView extends ReactScrollView {
|
||||
// The chat timeline's end pin, packages/app/src/agent-stream/native-end-pin.tsx.
|
||||
private static final String END_PIN_NATIVE_ID = "paseo-end-pin";
|
||||
|
||||
private final ViewTreeObserver.OnPreDrawListener clampBeforeDraw =
|
||||
() -> {
|
||||
clampToContentEnd();
|
||||
return true;
|
||||
};
|
||||
private @Nullable MaintainVisibleScrollPositionHelper.Config visibleContentPosition = null;
|
||||
private boolean isClampPending = false;
|
||||
private boolean isUserDragging = false;
|
||||
|
||||
PaseoScrollView(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMaintainVisibleContentPosition(
|
||||
@Nullable MaintainVisibleScrollPositionHelper.Config config) {
|
||||
super.setMaintainVisibleContentPosition(config);
|
||||
visibleContentPosition = config;
|
||||
}
|
||||
|
||||
// While the timeline follows its end, its end pin anchors the content position, so every line that
|
||||
// mounts scrolls the content by its height. A drag owns the offset from the frame it begins, and
|
||||
// JS removes the pin only a round trip later. While the user drags, the correction for the pin is
|
||||
// skipped, so the line grows below the rows under the finger. iOS's PaseoScrollViewComponentView
|
||||
// does the same.
|
||||
// MaintainVisibleScrollPositionHelper is the only caller: it moves the offset by its anchor's move.
|
||||
@Override
|
||||
public void scrollToPreservingMomentum(int x, int y) {
|
||||
if (isUserDragging && isEndPinTheAnchor()) return;
|
||||
super.scrollToPreservingMomentum(x, y);
|
||||
}
|
||||
|
||||
// The drag begins where React Native emits onScrollBeginDrag.
|
||||
@Override
|
||||
protected void handleInterceptedTouchEvent(MotionEvent ev) {
|
||||
super.handleInterceptedTouchEvent(ev);
|
||||
isUserDragging = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent ev) {
|
||||
boolean handled = super.onTouchEvent(ev);
|
||||
int action = ev.getActionMasked();
|
||||
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
|
||||
isUserDragging = false;
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
|
||||
// The helper anchors on the first content child from minIndexForVisible that reaches into the
|
||||
// viewport. The pin is the first child and sits at the bottom of the content, so it always does.
|
||||
private boolean isEndPinTheAnchor() {
|
||||
if (visibleContentPosition == null || visibleContentPosition.getMinIndexForVisible() != 0) {
|
||||
return false;
|
||||
}
|
||||
View content = getChildAt(0);
|
||||
if (!(content instanceof ViewGroup) || ((ViewGroup) content).getChildCount() == 0) return false;
|
||||
Object nativeId = ((ViewGroup) content).getChildAt(0).getTag(R.id.view_tag_native_id);
|
||||
return END_PIN_NATIVE_ID.equals(nativeId);
|
||||
}
|
||||
|
||||
// With maintainVisibleContentPosition set, a mount that shrinks the content past the offset would
|
||||
// apply the shrink twice:
|
||||
// 1. MaintainVisibleScrollPositionHelper.willMountItems records the anchor's frame.
|
||||
// 2. The mount lays out the shorter content, and ReactScrollView.onLayoutChange clamps the offset
|
||||
// to the new end.
|
||||
// 3. MaintainVisibleScrollPositionHelper.didMountItems adds the anchor's move to the clamped
|
||||
// offset, so the shrink moves the offset a second time (to the top, for a shrink at the end).
|
||||
// The clamp waits for the pre-draw pass instead, which runs after didMountItems and before the
|
||||
// frame draws: the anchor's move applies to the offset the content had before the mount, and the
|
||||
// clamp only catches what the anchor did not account for.
|
||||
// https://github.com/react/react-native/issues/58578 tracks it; remove this override once
|
||||
// https://github.com/react/react-native/pull/58695 ships in the React Native version we use.
|
||||
@Override
|
||||
public void onLayoutChange(
|
||||
View v,
|
||||
int left,
|
||||
int top,
|
||||
int right,
|
||||
int bottom,
|
||||
int oldLeft,
|
||||
int oldTop,
|
||||
int oldRight,
|
||||
int oldBottom) {
|
||||
if (visibleContentPosition == null) {
|
||||
super.onLayoutChange(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom);
|
||||
return;
|
||||
}
|
||||
if (!isClampPending) {
|
||||
isClampPending = true;
|
||||
getViewTreeObserver().addOnPreDrawListener(clampBeforeDraw);
|
||||
}
|
||||
ReactScrollViewHelper.emitLayoutChangeEvent(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
cancelPendingClamp();
|
||||
isUserDragging = false;
|
||||
super.onDetachedFromWindow();
|
||||
}
|
||||
|
||||
private void clampToContentEnd() {
|
||||
cancelPendingClamp();
|
||||
View content = getChildAt(0);
|
||||
if (content == null || content.getWidth() == 0 || content.getHeight() == 0 || !isShown()) {
|
||||
return;
|
||||
}
|
||||
int viewportHeight = getHeight() - getPaddingTop() - getPaddingBottom();
|
||||
int maxScrollY = Math.max(0, content.getHeight() - viewportHeight);
|
||||
if (getScrollY() > maxScrollY) {
|
||||
scrollTo(getScrollX(), maxScrollY);
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelPendingClamp() {
|
||||
if (!isClampPending) return;
|
||||
isClampPending = false;
|
||||
getViewTreeObserver().removeOnPreDrawListener(clampBeforeDraw);
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{ "platforms": ["android"], "android": { "modules": [] } }
|
||||
{ "platforms": ["android", "ios"], "android": { "modules": [] }, "ios": { "modules": [] } }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user