Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andClaude Opus 5.5 7c3c9109a0 fix(app): keep the context meter's details open after a tap
Ignoring the focus that follows a touch press applied to every hover card,
so on a wide touch screen the context window meter's details opened on the
tap and closed again after the close grace. Whether a card ignores touch is
now one decision on HoverCard, used for both the touch's pointerenter and the
focus after it. The workspace row opts in; the meter keeps opening on a tap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 20:11:05 +00:00
4cc3b69cbe test(app): fail when a tap opens the workspace hover card at all
The touch-tap test only checked that no card was left open after the tap,
which still passes when the touch's pointerenter opens the card during the
tap. That card is what makes iPad Safari drop the tap, so the test now records
whether the card mounted at any point. The trigger also reads the pointer
type from React Native's typed pointer event.

Co-authored-by: Zen <zoho.cn@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:56:00 +00:00
ZenandClaude Opus 5.5 543dc27244 fix(app): a tap on a touch screen opens a workspace row instead of its hover card
On a wide touch screen the workspace hover card opened on the tap's
pointerenter and on the focus the tap left behind. On iPad Safari that
changed the page under the finger, so the tap showed the card and never
opened the workspace. The workspace row's trigger now ignores touch, and
focus that follows a touch press no longer opens a hover card. Mouse hover
and keyboard focus open cards as before. Other triggers, such as the
context meter, which has no press of its own on wide screens, keep opening
on a touch's pointerenter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:56:00 +00:00
515 changed files with 3848 additions and 21078 deletions
+6 -1
View File
@@ -530,7 +530,7 @@
}
},
// Press feedback is owned by the primitives in components/ui. Raw pressable burn-down:
// 85 files. They may only leave this list.
// 94 files. They may only leave this list.
{
"files": [
"packages/app/src/components/ui/**/*.{ts,tsx}",
@@ -539,9 +539,12 @@
"packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
"packages/app/src/components/attachment-pill.tsx",
"packages/app/src/components/combined-model-selector.tsx",
@@ -580,6 +583,7 @@
"packages/app/src/components/terminal-emulator-native-grid.native.tsx",
"packages/app/src/components/terminal-pane.tsx",
"packages/app/src/components/toast-host.tsx",
"packages/app/src/components/tool-call-sheet.tsx",
"packages/app/src/components/welcome-screen.tsx",
"packages/app/src/components/workspace-hover-card.tsx",
"packages/app/src/components/zoomable-viewport/toolbar.tsx",
@@ -615,6 +619,7 @@
"packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx",
"packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx",
"packages/app/src/screens/workspace/workspace-tab-presentation.tsx",
"packages/app/src/tool-calls/detail-level/overview/sheet.tsx",
"packages/app/src/usage/sidebar-item.tsx",
"packages/app/src/usage/window-bar.tsx",
"packages/app/src/workspace-labels/manager-modal.tsx",
+1 -13
View File
@@ -186,8 +186,7 @@ Drop a hunk when the installed LegendList ships its fix.
`modules/paseo-scroll` replaces React Native's scroll view on both platforms with a subclass:
Android's `PaseoScrollView` and iOS's `PaseoScrollViewComponentView`.
`VisibleContentPositionClampTest` and `SelectableTextTouchTest` cover Android; iOS has no native
test harness.
`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
@@ -207,16 +206,5 @@ React Native 0.88 skips the adjustment for a removed anchor
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.
**Touched text (Android).** Chat text is selectable, and Android focuses selectable text when it is
touched. React Native scrolls a newly focused view fully into view, so tapping a link or a word, or
long-pressing to select, in a message partly off screen moved the timeline by up to a screen.
`PaseoScrollView` skips that scroll when the focused view is selectable, non-editable text in touch
mode. The text keeps focus, and a streaming reply's new text collapses its selection to the start,
which asks to reveal the reply's first line; that request is skipped too while nothing is selected.
A change in the timeline's height, such as the composer below it growing, also reveals the focused
view, and is skipped the same way. Inputs, focus moved by a keyboard, and a selection the user drags
still scroll into view. The inverted timeline guarded this with a rule tied to the inversion, which
went with it; keep this one while chat text is selectable.
Remove the content clamp and removed-anchor overrides once the installed React Native includes
their fixes.
+4 -4
View File
@@ -186,10 +186,10 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
## Scroll view corrections
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
keeps React Native's scroll manager interface and corrects how the chat timeline holds its position.
[agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections) lists the
corrections and the React Native issues they work around. Its instrumentation tests run the scroll
view in a real window, mounting through React Native's own mounting manager and injecting touches:
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
+6 -10
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which owns the pressed feedback: pass the pressed fill as `highlightStyle` and never paint it in your own style. iOS and web paint that fill; Android draws the platform ripple over the content instead, clipped to the row's corners. On native, feedback waits for a short press delay so a scroll that claims the touch shows none. Content that knocks out of the row's surface, such as a badge ring, follows the fill through `onHighlightChange`. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
@@ -40,11 +40,9 @@ Weight has three tiers, applied by role:
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text. The composer's empty placeholder is quieter; it uses `foregroundExtraMuted`.
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control, and for the composer's empty placeholder. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
A theme states places and identity; `packages/app/src/styles/derive-colors.ts` derives their relationships. Sidebar hover and selected fills step from the sidebar toward the foreground. For plugin themes, passive text on those states derives against the actual state surface; page tertiary text is a separate relationship. Explicit plugin semantic overrides take precedence over derived defaults. Built-in themes retain their chosen passive text. `packages/app/src/styles/theme-contrast.test.ts` checks the built-in and representative plugin palettes against contrast floors.
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
@@ -72,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
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()`.
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 its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Touch-sized toolbar glyphs are 20pt at the app's 1.5px stroke. An icon-only control's glyph matches the header icons and rests at `foreground`; a glyph beside a label rests at `foregroundMuted` so the two read as one quiet unit (`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.
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 its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. 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`). Touch-sized the disc keeps a 6pt clearance from its neighbour, because it is always painted and a pressed neighbour would merge with it. The attach button sits 2pt closer to the picker than its highlight would put it: the plus is all thin arms, so the full gap reads wide. That offset is set by eye.
@@ -114,8 +112,6 @@ Five primitives. The pick is determined by option count, the need to search, and
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
The sheet header (`SheetHeaderView`) is the only sheet title row. A sheet that brings its own Gorhom modal still renders it. Back, Close and every action passed through `header.actions` are `SheetHeaderIconButton`, so all of them get the header island's chrome, as `CompactHeader` does: one frame, glyph, colour and hover. The first and last buttons hang into the row's padding, so their glyphs sit on the content rail.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
@@ -136,7 +132,7 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand. Settings rows that share a menu surface with menu rows, such as Usage's options, go inside `<MenuSettingsRows>`, which puts their labels on the menu rows' label rail (`menuRowContentInset`).
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
The whitespace is the design.
@@ -271,7 +267,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
- Placeholder text dimmed beyond `foregroundMuted`, except the composer's empty placeholder, which uses `foregroundExtraMuted`. No extra opacity, no italics, no ghost-text.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
+1 -1
View File
@@ -49,7 +49,7 @@ than downloading a published desktop release.
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying agent and project JSON metadata and configuration. Both this setup and `PASEO_DEV_SEED_HOME` use the same seeder, which disables `agents.resumeOnStartup` in the copied config. Startup repairs copied running records without resuming agents or sending recovery prompts. Schedules and heartbeats (stored under `schedules/`), runtime files, identities, sockets, and logs are not copied. Existing nonempty homes are left unchanged unless explicitly reset. Copied agents still reference the original provider sessions and working directories: manually prompting one reaches that real session.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
Override knobs:
+9 -39
View File
@@ -7,12 +7,12 @@ canonical files and are trying to add or change one.
## Canonical files
| File | Use case |
| ------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
| `components/ui/autocomplete-popover/` | Slash-command autocomplete anchored to the focused composer input |
| File | Use case |
| ---------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
Each handles a different mix of concerns: combobox owns input focus, tooltip is
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
@@ -65,10 +65,6 @@ Two escape hatches in the codebase:
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
Use `@/components/ui/modal`, not React Native's `Modal`. On Android, React Native lays out a
modal's first frames at the screen size minus the status and navigation bars, then grows it to
the full edge-to-edge window, so a full-screen backdrop stops short of the bottom and snaps
down. The wrapper sizes the content to the window from the first frame.
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
hoists the React subtree to a fixed mount point whose bounds cover the
screen. Same window, same IME, hit-test works because the new parent is
@@ -284,22 +280,6 @@ popover or leaves it behind the keyboard. `measureAtRest` measures through
Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving`
settles; the composer's resting position changes as the draft grows.
A measured rect is an asynchronous copy, so it lands after the anchor's new
layout has painted. The composer resizes while the autocomplete is open: a
wrapped line, cleared text, a window resize. On web a `ResizeObserver` reads
the anchor's rect against the layer and commits the placement with `flushSync`,
which runs after layout and before paint. On native a Reanimated frame callback
reads only the anchor's height on the UI thread and holds its bottom edge at
the rest measurement. A height carries no transform. On iOS the absolute
position does not reliably include one: the keyboard lift runs on the native
driver and reaches the shadow tree only when the view re-renders. The
fixed-bottom model is wrong for the centered New workspace composer on tablets,
which grows around its middle, and for width or x changes. The popover is off
there by half the growth, or by the old width, until the next rest measurement.
Keep the portal's `KeyboardTranslateView` host-sized with
`pointerEvents="box-none"` and position the popover inside it, so the popover
moves without leaving its parent's bounds (Gotcha 1).
The provider also reconciles iOS from the controller's native `onEnd` event.
The controller's stock iOS shared values update at move start and during an
interactive move, but not at the terminal event, so JS contention can otherwise
@@ -442,11 +422,9 @@ gets that 0 and draws over the navigation bar.
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
- An RN `Modal` passes the call site's context straight through, so wrap its content in
`WindowSafeArea` from `@/window-layout` and read the inset below that. It provides the insets of
the whole window, published by `WindowLayoutProvider`. Do not put a `SafeAreaProvider` inside the
modal: on Android it measures the dialog window, whose bottom inset reads 24, then 0, then 24
again over the first frames, and everything padded by it jumps. See `attachment-lightbox.tsx`.
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
inside it and read the inset below that. The modal is its own window, and the provider measures
it. See `attachment-lightbox.tsx`.
## Recipe for a new anchored panel
@@ -475,14 +453,6 @@ Before you write a new one, ask:
`top` paints over its anchor until the next placement lands, about 150 ms
on Android. Web hides this because React commits the resize and the
re-placement in one task.
7. **Does the panel hold a list that reads away from its anchor?** Make the
anchor edge the list's resting scroll position: an inverted list, as in
`components/ui/autocomplete.tsx`. On Android a scroll command issued after
the rows commit reaches the screen one or two frames after them, so a list
scrolled to its anchor edge paints its far end first. Give each result set
its own list with a `key`, so it starts at the anchor edge with the rows
that fill the panel. A list that mounts every result holds the panel's
first frame until the last row is ready.
Then copy the closest canonical file and trim.
-32
View File
@@ -113,38 +113,6 @@ host that is either a known cloud host or one the CLI is already authenticated
to. Adapter probes must not make anonymous HTTP requests to remote-derived
hosts, and adapters must not route credentials to an unauthenticated host.
### Web links: `forgeWeb`
The daemon publishes `forgeWeb` on the workspace descriptor and on
`checkout_status_response`. The app builds every forge URL from it instead of
parsing `origin` itself.
- `host` is the browsable host: an `ssh -G` alias such as `github.com-work` and
a cloud SSH host such as `ssh.github.com` resolve to the cloud host; self-hosted
hosts pass through.
- `referenceRepository` is the repository that owns issue and change-request
numbers: the parent when `origin` is a fork. `#123` links there; `owner/repo#123`
keeps the repository you wrote.
- Branch and file links stay on the `origin` repository. A feature branch usually
exists only on the fork.
An adapter opts into `referenceRepository` with the optional
`ForgeService.getReferenceRepository`. GitHub reads `parent` from `gh repo view`,
GitLab reads `forked_from_project` from `glab api`, and the Gitea family reads
`parent` from `tea api`. The lookup always names the `origin` repository
explicitly, so it does not depend on `gh`'s default-repository choice, and it
rejects on any CLI failure. `packages/server/src/server/forge-web.ts` keeps a
successful answer for as long as the remote stays the same. The first snapshot
publishes `{ host }` immediately and the reference repository follows in a later
update, so a slow or signed-out forge CLI never holds back the checkout's git
state or pull request status. A failed lookup (signed out, network down) leaves
links on `origin` and is retried by the next refresh or pull request status poll
once a minute has passed.
Daemons older than v0.16.0 omit the field; the app then derives the host from the
remote and links `#N` to `origin`. That fallback is tagged `COMPAT(forgeWeb)` in
`packages/app/src/git/forge-url.ts`.
## App
Each app forge splits into two modules so pure logic never imports the client
-4
View File
@@ -61,10 +61,6 @@ The daemon journals the assigned agent ID before creation. Concurrent retries sh
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
A direct send (one without `sourceAgentId`) rejected before its prompt is handed to a run, for
example because the agent could not be loaded or the requested mode could not be set, leaves no
receipt, so retrying its message ID submits it. A failure during or after run startup is treated
as an unfinished delivery.
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
+3 -12
View File
@@ -20,16 +20,9 @@ settings the native composer does not implement.
## Native sources
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
after the focused text control has already handled the press, so the pipeline's
`preventDefault` cannot stop a key from typing. A key the text control would type
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
active in the current focus scope, such as Shift+Tab in the composer.
`sources/native.ts` computes the claims from the published bindings and recomputes
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
text controls do not type them. Android takes no claims: a focused `EditText`
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
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
@@ -59,8 +52,6 @@ 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.
Declaring Tab takes it from the web focus trap and hands it to `onKeyDown`; the command
center rotates its tabs with it. Native sources drop Tab from the declarations.
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
+4 -13
View File
@@ -27,17 +27,9 @@ 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`.
An overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`.
A compact Settings visit entered from the sidebar suspends it: the host stays mounted and
laid out on native. Its target and scroll position survive until return to the originating
workspace. Route changes and `onLayout` both precede native screen appearance, so the sidebar
stays visible until Settings emits the native navigator's `transitionEnd`. Web uses layout.
Input and activity stop during the handoff. Widening or leaving for another destination discards
the return. Commands issued while suspended settle immediately behind Settings, so explicit
agent navigation does not reveal a closing sidebar animation.
App chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`,
`dock`, `hidden`, or `suspended`) to `panel-store`, which drops any target the layout does not present. A stale
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`.
@@ -128,8 +120,7 @@ definition, no longer eligible to begin.
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:
workspace destinations close the overlay before navigating. Settings suspends the compact
sidebar for the duration of its visit instead.
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.
-15
View File
@@ -624,21 +624,6 @@ one section's order from built-ins, plugin groups, and the section's preference
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute workspace row badge items
The [public reference](../public-docs/plugins/reference.md#workspace-row-badge-items) owns the author API.
The host lives in `packages/app/src/plugins/workspace-row-badge-items/`; `WorkspaceMetaRow` draws a host's
items after its own.
- Plugin items render with no wrapper view. The meta line spaces children with `gap`, so an empty
wrapper would leave a gap on rows the plugin has nothing to say about. That is also why
the row adds no separator around plugin items; a plugin renders its slot.
- A row only renders items from plugins on its own host. The Show menu coalesces items across hosts
by `<pluginId>/<itemId>`, the key in the `sidebarPluginRowBadgeItems` setting, and places them in
their own group below a separator. A missing key is visible.
- Previews (Settings > Host appearance) pass no `workspace` to `WorkspaceMetaRow`, so plugin items
stay off them.
## Contribute file panels
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
-9
View File
@@ -150,15 +150,6 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
OMP rewind is a branch, not an edit: `branch` writes a new session file whose header names the
previous file as `parentSession` and leaves that previous file untouched. A rewind chain therefore
adds one file to `~/.omp/agent/sessions` per step, and `omp --resume` lists all of them. The
previous file also keeps whatever head it had recorded, which can be an older branch than the one
the user last saw. Treat the provider session file selected by OMP as the authority; do not assume
the newest file or the newest message wins. Import listings hand back a file path, so persist the
resumed session's own identity (`sessionId`) with that path as `nativeHandle`, not the listed
handle as both.
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
+1 -2
View File
@@ -248,7 +248,6 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
- The user-facing beta instructions, including the public TestFlight join link for the `Paseo Beta` group, live in `public-docs/updates.md` and its `zh-CN` pair. Update them when a beta path is added or removed.
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
@@ -489,7 +488,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
- The website download page defaults to GitHub's latest published **stable** release.
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
- Homebrew, the Play Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. The iOS row swaps the App Store pill for the public TestFlight link (`testFlightBetaUrl` in `packages/website/src/downloads.tsx`). When another surface gains a beta path, add its row back in `packages/website/src/routes/{-$locale}/download.tsx`.
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
+4 -5
View File
@@ -95,11 +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. A tool call that started before the checkpoint
and changed after it, such as background work that outlives a reply, is copied as still running:
the projection keeps only its latest payload, so its state at the checkpoint can't be read back.
Plugin cards are not copied. A position from another epoch, or one that is gone, is refused;
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
+1 -1
View File
@@ -346,7 +346,7 @@ Gotchas:
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Settings retains its screen and form; `AppearanceText` refreshes numeric text styles at the leaf, and its font fields use `withUnistyles` to refresh input styles without replacing the inputs. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator.
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
@@ -5,7 +5,7 @@ import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
test("agent list age advances and command center says just now for a fresh workspace", async ({
test("agent list age advances and command center says just now for a fresh agent", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
@@ -23,10 +23,10 @@ test("agent list age advances and command center says just now for a fresh works
const commandCenter = await openCommandCenter(page);
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
const workspaceResult = commandCenter.getByTestId(
`command-center-workspace-${getServerId()}:${session.workspaceId}`,
const agentResult = commandCenter.getByTestId(
`command-center-agent-${getServerId()}:${session.agentId}`,
);
await expect(workspaceResult.getByTestId("command-center-result-timestamp")).toContainText(
await expect(agentResult.getByTestId("command-center-result-timestamp")).toContainText(
"just now",
);
await page.keyboard.press("Escape");
@@ -37,7 +37,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 213)");
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
await page.screenshot({
path: testInfo.outputPath("light-selected-workspace.png"),
fullPage: true,
@@ -64,7 +64,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(29, 29, 29)");
await expect(row).toHaveCSS("background-color", "rgb(17, 17, 17)");
await page.screenshot({
path: testInfo.outputPath("pure-black-selected-workspace.png"),
fullPage: true,
@@ -644,22 +644,6 @@ test("copying an assistant selection preserves Markdown structure and links", as
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
);
// Crossing into part of the block keeps the selected code lines as a fenced block.
await selectAssistantAcrossCodeLines(page, "not a generated link.", "return");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```',
);
// So does starting partway through the block and leaving it.
await selectAssistantAcrossCodeLines(page, "answer", "After code.");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.',
);
// Selecting a whole line runs off its end into the newline node. A trailing
// newline auto-executes the line when it is pasted into a terminal.
await selectAssistantAcrossCodeLines(page, "const", " ");
@@ -745,25 +729,13 @@ test.describe("on a touch-capable desktop browser", () => {
// 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 list, quote, and code blocks", async ({ page }) => {
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-drag-",
title: "Assistant selection drag",
initialPrompt: "Render structured text.",
initialPrompt: "Render three paragraphs.",
featureValues: {
mockAssistantResponse: [
"First paragraph.",
"",
"- List item.",
"",
"> Quote text.",
"",
"```ts",
"const answer = 1;",
"```",
"",
"Third paragraph.",
].join("\n"),
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
},
});
@@ -788,9 +760,6 @@ test.describe("on a touch-capable desktop browser", () => {
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
expect(selected).toContain("First paragraph.");
expect(selected).toContain("List item.");
expect(selected).toContain("Quote text.");
expect(selected).toContain("const answer = 1;");
expect(selected).toContain("Third paragraph.");
} finally {
await agent.cleanup();
@@ -2297,9 +2297,13 @@ async function changeCodeTypographyFromSettings(
await page.getByTestId("sidebar-settings").click();
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
await page.getByRole("button", { name: "Appearance" }).click();
// A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts
// from the stored size. Edit the size only in the remounted form, or the remount drops it.
const sizeField = page.getByLabel("Code font size");
await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", ""));
await page.getByLabel("Code font family").fill(typography.fontFamily);
await page.getByLabel("Code font family").press("Enter");
await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
await sizeField.fill(String(typography.fontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
@@ -1,12 +1,5 @@
import { expect, test } from "../support/fixtures";
import { openSettings } from "../support/helpers/app";
import {
closeCommandCenter,
expectCommandCenterTab,
expectCommandCenterTabs,
openCommandCenter,
selectCommandCenterTab,
} from "../support/helpers/command-center";
import { openCommandCenter } from "../support/helpers/command-center";
import {
delayDirectorySuggestionResponses,
holdDirectorySuggestionResponses,
@@ -24,7 +17,6 @@ import {
} from "../support/helpers/command-center-scroll";
import { expectFileTabOpen } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { getServerId } from "../support/helpers/server-id";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const FILE_PATH =
@@ -75,7 +67,7 @@ test("workspace file search stays geometrically stable through delayed loading a
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expectCommandCenterTab(panel, "Files");
await expect(panel.getByRole("button", { name: "Files" })).toBeVisible();
await startCommandCenterLayoutObservation(page);
await panel.getByTestId("command-center-input").fill(FILE_NAME);
@@ -105,90 +97,26 @@ test("workspace file search stays geometrically stable through delayed loading a
}
});
test("command center tabs rotate with Tab and never move the header", async ({ page }) => {
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
const seeded = await seedWorkspace({
repoPrefix: "command-center-tabs-",
title: "Tab rotation",
repoPrefix: "command-center-file-search-scope-",
title: "Scope chip height",
});
try {
await gotoWorkspace(page, seeded.workspaceId);
await page.keyboard.press("Meta+P");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
const header = panel.getByTestId("command-center-header");
const input = panel.getByTestId("command-center-input");
const scoped = await header.boundingBox();
await test.step("⌘P opens on Files and Backspace on an empty field returns to All", async () => {
await page.keyboard.press("Meta+P");
await expect(panel).toBeVisible({ timeout: 30_000 });
await expectCommandCenterTab(panel, "Files");
await page.keyboard.press("Backspace");
await expectCommandCenterTab(panel, "All");
});
await page.getByTestId("command-center-files-scope").click();
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
const unscoped = await header.boundingBox();
const results = panel.getByTestId("command-center-results");
const headerOnAll = await header.boundingBox();
const resultsTopOnAll = (await results.boundingBox())?.y;
await test.step("Tab walks forward and wraps; Shift+Tab walks back", async () => {
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Workspaces");
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Files");
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Actions");
expect(await header.boundingBox()).toEqual(headerOnAll);
expect((await results.boundingBox())?.y).toBe(resultsTopOnAll);
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "All");
await page.keyboard.press("Shift+Tab");
await expectCommandCenterTab(panel, "Actions");
await page.keyboard.press("Shift+Tab");
await expectCommandCenterTab(panel, "Files");
});
await test.step("rotating keeps the query", async () => {
await input.fill("tab rot");
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Actions");
await expect(input).toHaveValue("tab rot");
});
await test.step("clicking a tab keeps typing in the search field", async () => {
await selectCommandCenterTab(panel, "Workspaces");
await page.keyboard.press("Backspace");
await expect(input).toHaveValue("tab ro");
await expect(
panel.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`),
).toBeVisible();
});
await test.step("⌘K opens on All", async () => {
await page.keyboard.press("Escape");
await expect(panel).not.toBeVisible();
await page.keyboard.press("Meta+K");
await expectCommandCenterTab(panel, "All");
});
await test.step("in a workspace every tab is offered", async () => {
await expectCommandCenterTabs(panel, ["All", "Workspaces", "Files", "Actions"]);
});
await test.step("outside a workspace there is no Files tab, and Tab skips it", async () => {
const outside = { workspaceOpen: false };
await closeCommandCenter(page);
await openSettings(page);
await page.keyboard.press("Meta+K");
await expectCommandCenterTab(panel, "All", outside);
await expectCommandCenterTabs(panel, ["All", "Workspaces", "Actions"]);
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Workspaces", outside);
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "Actions", outside);
await page.keyboard.press("Tab");
await expectCommandCenterTab(panel, "All", outside);
await page.keyboard.press("Shift+Tab");
await expectCommandCenterTab(panel, "Actions", outside);
});
expect(scoped?.height).toBe(unscoped?.height);
} finally {
await seeded.cleanup();
}
@@ -7,8 +7,8 @@ import {
searchCommandCenter,
warmCommandCenterRows,
expectOnlyCommandCenterRowRendered,
expectCommandCenterWorkspaceWindow,
scrollToOldestCommandCenterWorkspace,
expectCommandCenterAgentWindow,
scrollToOldestCommandCenterAgent,
} from "../support/helpers/command-center-performance";
import { gotoAppShell } from "../support/helpers/app";
import {
@@ -19,7 +19,7 @@ import {
expectPrimaryCommandCenterActions,
expectTightTwoLineResultSpacing,
expectVisibleKeyboardNavigationNotToScroll,
expectRecentWorkspacesUntilSearch,
expectWorkspaceResultsToBeLimitedUntilSearch,
observeCommandCenterScroll,
openCommandCenterWithKeyboard,
} from "../support/helpers/command-center-scroll";
@@ -63,9 +63,9 @@ test("Command Center preserves manual scrolling through live updates and resumes
await expectCommandCenterToRemainAtTheTop(page);
await expectPrimaryCommandCenterActions(panel);
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
// All leads with the most recent workspaces, so the last one seeded leads it.
// The empty query lists the most recent workspaces, so the last one seeded leads it.
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
await expectRecentWorkspacesUntilSearch(panel);
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed");
await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1);
@@ -76,50 +76,42 @@ test("Command Center preserves manual scrolling through live updates and resumes
}
});
test("live search updates render only the changed row and keep workspaces virtualized", async ({
test("live search updates render only the changed row and keep agents virtualized", async ({
page,
}) => {
test.setTimeout(120_000);
const seeded = await seedSearchableCommandCenter();
const agentWorkspaceRowId = `workspace:${getServerId()}:${seeded.workspaceId}`;
try {
await observeCommandCenterRowRenders(page);
await gotoAppShell(page);
const panel = await openCommandCenterWithKeyboard(page);
await searchCommandCenter(panel, "Search agent");
await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
await test.step("renaming a matched agent re-renders only its workspace", async () => {
await searchCommandCenter(panel, "Search agent 002");
const agentLine = panel
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
.getByTestId("command-center-workspace-agent");
await expect(agentLine).toHaveText("Search agent 002");
await warmCommandCenterRows(page);
await seeded.client.updateAgent(seeded.agents[2].id, { name: "Search agent 002 revised" });
await expect(agentLine).toHaveText("Search agent 002 revised");
await expectOnlyCommandCenterRowRendered(page, agentWorkspaceRowId);
});
await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" });
await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`agent:${getServerId()}:${seeded.agents[59].id}`,
);
await test.step("renaming a workspace re-renders only its row", async () => {
await searchCommandCenter(panel, "Search workspace");
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await seeded.client.setWorkspaceTitle(
seeded.workspaces[39].id,
"Search workspace 039 revised",
);
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
);
});
await searchCommandCenter(panel, "Search workspace");
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
await warmCommandCenterRows(page);
await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised");
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
await expectOnlyCommandCenterRowRendered(
page,
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
);
await test.step("a long list mounts only a window of rows", async () => {
await expectCommandCenterWorkspaceWindow(panel, seeded.workspaces.length + 1);
await scrollToOldestCommandCenterWorkspace(panel);
await expect(panel.getByText("Search workspace 000", { exact: true })).toBeVisible();
await expectCommandCenterWorkspaceWindow(panel, seeded.workspaces.length + 1);
});
await searchCommandCenter(panel, "Search agent");
await warmCommandCenterRows(page);
await scrollToOldestCommandCenterAgent(panel);
await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible();
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
} finally {
await seeded.cleanup();
}
@@ -4,12 +4,7 @@ import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { expectAgentTabActive } from "../support/helpers/launcher";
import {
commandCenterSection,
expectCommandCenterTab,
openCommandCenter,
selectCommandCenterTab,
} from "../support/helpers/command-center";
import { openCommandCenter } from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { seedWorkspace } from "../support/helpers/seed-client";
@@ -26,7 +21,9 @@ const AGENT_TITLE = "Fix checkout retries";
test.describe("Command center workspaces", () => {
test.describe.configure({ timeout: 180_000 });
test("workspace results overlay live status", async ({ page }) => {
test("agent and workspace results retain their icons and overlay live status", async ({
page,
}) => {
const workspace = await seedRunningMockAgentWorkspace({
repoPrefix: "command-center-status-",
title: "Review checkout retries",
@@ -37,13 +34,15 @@ test.describe("Command center workspaces", () => {
await openAgentRoute(page, workspace);
await expectAgentTabActive(page, workspace.agentId);
const panel = await openCommandCenter(page);
// Recent workspaces leaves out the open one; the Workspaces tab lists every workspace.
await selectCommandCenterTab(panel, "Workspaces");
const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`);
const row = panel.getByTestId(
`command-center-workspace-${getServerId()}:${workspace.workspaceId}`,
);
await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible();
await workspace.client.cancelAgent(workspace.agentId);
await expect(agent.getByRole("progressbar")).toHaveCount(0);
await expect(row.getByRole("progressbar")).toHaveCount(0);
} finally {
await workspace.cleanup();
@@ -81,11 +80,9 @@ test.describe("Command center workspaces", () => {
});
const panel = await openCommandCenter(page);
await selectCommandCenterTab(panel, "Workspaces");
const row = panel.getByTestId(
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
);
const agentLine = row.getByTestId("command-center-workspace-agent");
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(WORKSPACE_TITLE);
await expect(row).toContainText(WORKSPACE_BRANCH);
@@ -95,151 +92,65 @@ test.describe("Command center workspaces", () => {
await expect(subtitle).toContainText(PRIMARY_HOST_LABEL);
await expect(subtitle).toContainText(seeded.projectDisplayName);
await expect(subtitle).toContainText(WORKSPACE_BRANCH);
await expect(agentLine).toHaveCount(0);
await test.step("a workspace matches on its own fields", async () => {
const input = panel.getByTestId("command-center-input");
for (const query of [
PRIMARY_HOST_LABEL,
WORKSPACE_BRANCH,
WORKSPACE_TITLE,
seeded.projectDisplayName,
]) {
await input.fill(query);
await expect(row).toBeVisible();
await expect(agentLine).toHaveCount(0);
}
// `cwd` is never shown, so it must never match.
await input.fill(seeded.repoPath);
await expect(row).not.toBeVisible();
// The agent subtitle is unchanged by the shared-helper refactor.
const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(agentRow).toContainText(AGENT_TITLE);
await expect(agentRow).toContainText(PRIMARY_HOST_LABEL);
await expect(agentRow).toContainText(WORKSPACE_TITLE);
await expect(agentRow).not.toContainText(seeded.repoPath);
const workspaceSectionTop = await panel
.getByText("Workspaces", { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
const agentSectionTop = await panel
.getByText("Agents", { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
expect(workspaceSectionTop).toBeLessThan(agentSectionTop);
const input = panel.getByTestId("command-center-input");
await input.fill(PRIMARY_HOST_LABEL);
await expect(row).toBeVisible();
await expect(agentRow).toBeVisible();
await input.fill(WORKSPACE_BRANCH);
await expect(row).toBeVisible();
await expect(agentRow).not.toBeVisible();
await input.fill(WORKSPACE_TITLE);
await expect(row).toBeVisible();
await expect(agentRow).toBeVisible();
await input.fill(seeded.repoPath);
await expect(agentRow).toBeVisible();
await expect(row).not.toBeVisible();
// The project name is now part of the workspace searchText.
await input.fill(seeded.projectDisplayName);
await expect(row).toBeVisible();
await input.fill(AGENT_TITLE);
await expect(agentRow).toBeVisible();
await expect(row).not.toBeVisible();
await input.fill(WORKSPACE_TITLE);
await page.keyboard.press("Enter");
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
});
await test.step("an agent title finds its workspace and names the agent", async () => {
await panel.getByTestId("command-center-input").fill(AGENT_TITLE);
await expect(agentLine).toHaveText(AGENT_TITLE);
await expect(panel.getByText("Agents", { exact: true })).toHaveCount(0);
await test.step("find an agent and open its workspace tab", async () => {
const agentPanel = await openCommandCenter(page);
await agentPanel.getByTestId("command-center-input").fill(AGENT_TITLE);
await page.keyboard.press("Enter");
await expectAgentTabActive(page, agent.id);
});
await test.step("a workspace match opens the workspace", async () => {
const searchAgain = await openCommandCenter(page);
await expectCommandCenterTab(searchAgain, "All");
await searchAgain.getByTestId("command-center-input").fill(WORKSPACE_TITLE);
await page.keyboard.press("Enter");
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
});
});
} finally {
await seeded.cleanup();
}
});
test("All leads with the three most recent workspaces other than the open one", async ({
page,
}) => {
const older = [];
for (const title of ["Recent oldest", "Recent third", "Recent second", "Recent newest"]) {
older.push(await seedWorkspace({ repoPrefix: "command-center-recent-", title }));
}
const current = await seedWorkspace({
repoPrefix: "command-center-recent-",
title: "Open one",
});
try {
await page.goto(buildHostWorkspaceRoute(getServerId(), current.workspaceId));
const panel = await openCommandCenter(page);
await expectCommandCenterTab(panel, "All");
await expect(commandCenterSection(panel, "Recent workspaces")).toBeVisible();
const rowOf = (workspaceId: string) =>
panel.getByTestId(`command-center-workspace-${getServerId()}:${workspaceId}`);
await expect(rowOf(older[3].workspaceId)).toBeVisible({ timeout: 30_000 });
await expect(rowOf(older[2].workspaceId)).toBeVisible();
await expect(rowOf(older[1].workspaceId)).toBeVisible();
await expect(rowOf(older[0].workspaceId)).toHaveCount(0);
await expect(rowOf(current.workspaceId)).toHaveCount(0);
await expect(commandCenterSection(panel, "Workspaces")).toHaveCount(0);
// The newest recent workspace is selected, so Enter goes straight back to it.
await page.keyboard.press("Enter");
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), older[3].workspaceId), {
timeout: 30_000,
});
} finally {
for (const seeded of [current, ...older]) await seeded.cleanup();
}
});
test("archived workspaces follow active matches and open where they can be unarchived", async ({
page,
}) => {
const active = await seedWorkspace({
repoPrefix: "command-center-archived-active-",
title: "Billing dashboard",
});
const archived = await seedWorkspace({
repoPrefix: "command-center-archived-",
title: "Billing cleanup",
});
try {
await createIdleAgent(archived.client, {
cwd: archived.repoPath,
workspaceId: archived.workspaceId,
title: "Audit invoice totals",
});
expect((await archived.client.archiveWorkspace(archived.workspaceId)).error).toBeNull();
await gotoAppShell(page);
const panel = await openCommandCenter(page);
const input = panel.getByTestId("command-center-input");
const activeRow = panel.getByTestId(
`command-center-workspace-${getServerId()}:${active.workspaceId}`,
);
const archivedRow = panel.getByTestId(
`command-center-workspace-${getServerId()}:${archived.workspaceId}`,
);
await test.step("an empty query never lists archived workspaces", async () => {
await selectCommandCenterTab(panel, "Workspaces");
await expect(activeRow).toBeVisible({ timeout: 30_000 });
await expect(archivedRow).toHaveCount(0);
});
await test.step("a query lists the archived workspace after the active one", async () => {
await input.fill("billing");
await expect(archivedRow).toBeVisible({ timeout: 30_000 });
await expect(archivedRow).toContainText("Archived");
await expect(activeRow).not.toContainText("Archived");
const activeTop = (await activeRow.boundingBox())?.y ?? Number.NaN;
const archivedTop = (await archivedRow.boundingBox())?.y ?? Number.NaN;
expect(archivedTop).toBeGreaterThan(activeTop);
});
await test.step("an agent title finds the archived workspace on All", async () => {
// Opened from the app shell, outside any workspace.
await selectCommandCenterTab(panel, "All", { workspaceOpen: false });
await input.fill("invoice totals");
await expect(archivedRow.getByTestId("command-center-workspace-agent")).toHaveText(
"Audit invoice totals",
{ timeout: 30_000 },
);
await expect(activeRow).toHaveCount(0);
});
await test.step("opening it lands where the workspace can be unarchived", async () => {
await page.keyboard.press("Enter");
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible();
});
} finally {
await active.cleanup();
await archived.cleanup();
}
});
test("single-host workspace subtitle omits the host and shows project · branch", async ({
page,
}) => {
@@ -318,7 +229,7 @@ test.describe("Compact sidebar inspection", () => {
title: "Compact destination",
});
try {
await createIdleAgent(seeded.client, {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Compact root agent",
@@ -335,10 +246,7 @@ test.describe("Compact sidebar inspection", () => {
await openSidebar(page);
const agentSearch = await openCommandCenter(page);
await agentSearch.getByTestId("command-center-input").fill("Compact root agent");
await agentSearch
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
.getByTestId("command-center-workspace-agent")
.click();
await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click();
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Message agent...", exact: true }),
@@ -787,13 +787,9 @@ test.describe("Composer autocomplete", () => {
await input.press("ArrowDown");
await input.press("ArrowDown");
await input.press("h");
// On web the search reads the cursor after paint, so the input's value runs ahead of
// the list. Wait on the list itself: /tdd is hidden only while the search is "h", and
// back only once the search is "" again.
await expect(popover.getByText("/tdd", { exact: true })).toHaveCount(0);
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
await input.press("Backspace");
await expect(input).toHaveValue("/");
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible();
await input.press("Enter");
await expect(input).toHaveValue("/tdd ");
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { seedSubagent } from "../support/helpers/subagents";
import { expectWorkspaceHeaderVisible, submitMessage } from "../support/helpers/composer";
import { submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
import {
@@ -918,12 +918,11 @@ test.describe("touch composer tracks", () => {
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
await page.setViewportSize({ width: 200, height: 844 });
// The compact layout mounts a new composer, so measure only after it replaces the wide one.
await expectWorkspaceHeaderVisible(page);
for (const [index, pill] of [tasks, subagents, changes].entries())
await expect
.poll(async () => (await pill.boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(wideWidths[index] - 1);
const narrowWidths = await Promise.all(
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
for (const [index, width] of wideWidths.entries())
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
const track = page.getByTestId("composer-track-scroll");
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
await track.evaluate((node) => node.clientWidth),
@@ -1,131 +0,0 @@
import { expect, test } from "../support/fixtures";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import {
composerControl,
expectGlyphMatchesLabel,
expectIconOnlyGlyph,
readComposerControlTone,
readComposerPlaceholderTone,
readComposerTextTone,
} from "../support/helpers/composer-toolbar-tone";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const THINKING = "agent-thinking-selector";
const MODE = "mode-control";
const ATTACH = "message-input-attach-button";
const COMBINED_MODEL = "combined-model-selector";
async function openSeededComposer(
page: import("@playwright/test").Page,
repoPrefix: string,
readyTestId = MODE,
) {
const agent = await seedMockAgentWorkspace({
repoPrefix,
title: "Toolbar tone",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await expect(composerControl(page, readyTestId)).toBeVisible({ timeout: 30_000 });
return agent;
} catch (error) {
await agent.cleanup();
throw error;
}
}
test.describe("Composer toolbar glyph tone", () => {
test.describe.configure({ timeout: 180_000 });
test.describe("on a touch screen", () => {
test.use({ hasTouch: true, viewport: { width: 1024, height: 1366 } });
test("a glyph beside its label matches the label while an icon-only glyph stays foreground", async ({
page,
}) => {
const agent = await openSeededComposer(page, "composer-tone-touch-");
try {
const thinking = await readComposerControlTone(composerControl(page, THINKING));
const mode = await readComposerControlTone(composerControl(page, MODE));
const attach = await readComposerControlTone(composerControl(page, ATTACH));
expectGlyphMatchesLabel(thinking, "thinking");
expectGlyphMatchesLabel(mode, "mode");
expectIconOnlyGlyph(attach, "attach");
expect(attach.glyph, "an icon-only glyph is brighter than a labelled glyph").not.toBe(
thinking.label,
);
} finally {
await agent.cleanup();
}
});
});
test.describe("on a compact non-touch window", () => {
test.use({ hasTouch: false, viewport: { width: 590, height: 844 } });
test("the icon-only thinking glyph stays foreground and the labelled mode glyph matches its label", async ({
page,
}) => {
const agent = await openSeededComposer(page, "composer-tone-compact-");
try {
const thinking = await readComposerControlTone(composerControl(page, THINKING));
const mode = await readComposerControlTone(composerControl(page, MODE));
const attach = await readComposerControlTone(composerControl(page, ATTACH));
expectIconOnlyGlyph(thinking, "thinking");
expectGlyphMatchesLabel(mode, "mode");
expectIconOnlyGlyph(attach, "attach");
expect(thinking.glyph, "the icon-only glyphs share one tone").toBe(attach.glyph);
expect(thinking.glyph, "an icon-only glyph is brighter than a labelled glyph").not.toBe(
mode.label,
);
} finally {
await agent.cleanup();
}
});
});
test.describe("on a folded compact non-touch window", () => {
test.use({ hasTouch: false, viewport: { width: 480, height: 844 } });
test("the empty composer placeholder is quieter than its labels", async ({ page }) => {
const agent = await openSeededComposer(page, "composer-tone-placeholder-", COMBINED_MODEL);
try {
const placeholder = await readComposerPlaceholderTone(composerLocator(page));
const extraMuted = await readComposerTextTone(
composerControl(page, "combined-model-selector-thinking-label"),
);
const muted = await readComposerTextTone(
composerControl(page, "combined-model-selector-model-label"),
);
expect(placeholder, "the composer paints a placeholder").not.toBeNull();
expect(extraMuted, "the folded thinking label paints the extra-muted tone").not.toBeNull();
expect(placeholder, "the placeholder uses the extra-muted tone").toBe(extraMuted);
expect(placeholder, "the placeholder is quieter than a labelled glyph").not.toBe(muted);
} finally {
await agent.cleanup();
}
});
});
test.describe("with a wide pointer", () => {
test.use({ hasTouch: false, viewport: { width: 1280, height: 800 } });
test("a labelled glyph stays muted and matches its label", async ({ page }) => {
const agent = await openSeededComposer(page, "composer-tone-pointer-");
try {
const thinking = await readComposerControlTone(composerControl(page, THINKING));
const mode = await readComposerControlTone(composerControl(page, MODE));
expectGlyphMatchesLabel(thinking, "thinking");
expectGlyphMatchesLabel(mode, "mode");
} finally {
await agent.cleanup();
}
});
});
});
@@ -26,6 +26,7 @@ import {
reloadAgent,
scriptAgentUsage,
seedAgentWithContextWindow,
tapContextWindowMeter,
usageCard,
} from "../support/helpers/context-window";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -509,6 +510,24 @@ test("context details stay open while the chat streams", async ({ page }) => {
}
});
test.describe("on a wide touch screen", () => {
test.use({ hasTouch: true });
test("tapping the meter opens context details that stay open", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const [claude] = claudeAndCodexReports();
// A tap can request the agent's usage more than once.
usage.answerNext([onPersonalLogin(claude!)]);
usage.answerNext([onPersonalLogin(claude!)]);
const details = await tapContextWindowMeter(page);
// Past the card's close grace. A touch sends no pointer movement after the tap.
await page.waitForTimeout(500);
await expect(details).toBeVisible();
});
});
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
@@ -127,59 +127,8 @@ for (const scenario of [
await creation.submitPrompt("Retry this workspace and agent together.");
await creation.expectAgentCount(1);
});
test(`an edited prompt after failed agent initialization starts the agent in its workspace${scenario.suffix}`, async ({
creation,
startup,
page,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Start the original task.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await scenario.prepare(creation);
await creation.submitPrompt("Start the edited task.");
await creation.expectPromptVisible("Start the edited task.");
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
});
}
test("retrying failed agent initialization keeps the plugin metadata set on New workspace", async ({
creation,
startup,
composerMetadata,
}) => {
await creation.openWorkspaceForm("local");
await composerMetadata.set({ "composer-metadata.tag": "kept" });
await creation.submitPrompt("Start the tagged task.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await creation.submitPrompt("Start the tagged task.");
await creation.expectAgentCount(1);
await creation.expectAgentMetadata({ "composer-metadata.tag": "kept" });
});
test("an edited agent draft starts after its first start failed", async ({
creation,
startup,
page,
}) => {
await creation.openAgentDraft();
await startup.fail();
await creation.submitPrompt("Start the original task.");
await creation.expectStartupFailure();
await creation.submitPrompt("Start the edited task.");
await creation.expectPromptVisible("Start the edited task.");
await creation.expectAgentCount(1);
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
});
for (const entry of [
{
name: "agent draft",
@@ -210,9 +159,7 @@ for (const entry of [
await entry.settled(creation);
await creation.expectCreatedAgentError();
await creation.expectPromptBeforeError(prompt);
await page.screenshot({
path: testInfo.outputPath("created-agent-prompt-error.png"),
});
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
await creation.reloadAgent();
await creation.expectPromptBeforeError(prompt);
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
@@ -3,7 +3,6 @@ import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import {
downloadFileFromPreview,
expandFolder,
openFileExplorer,
openFileFromExplorer,
@@ -142,29 +141,6 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("downloads an assistant-linked binary outside the workspace", async ({ page }, testInfo) => {
const filePath = testInfo.outputPath("outside.bin");
const source = Buffer.from([0, 1, 128, 255]);
await writeFile(filePath, source);
const session = await seedMockAgentWorkspace({
repoPrefix: "outside-binary-",
title: "Outside binary",
initialPrompt: "Show the file",
featureValues: { mockAssistantResponse: `[Open binary](${filePath})` },
});
try {
await openAgentRoute(page, session);
await openAssistantMarkdownLink(page, "Open binary");
const downloaded = await downloadFileFromPreview(page);
expect(downloaded.suggestedFilename()).toBe("outside.bin");
const savedFile = testInfo.outputPath("downloaded.bin");
await downloaded.saveAs(savedFile);
expect(await readFile(savedFile)).toEqual(source);
} finally {
await session.cleanup();
}
});
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "screenshot-markdown-link-",
@@ -2,12 +2,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import {
downloadFileFromExplorer,
downloadFileFromPreview,
openFileFromExplorer,
openFileExplorer,
} from "../support/helpers/file-explorer";
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { gotoWorkspace } from "../support/helpers/launcher";
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
@@ -478,21 +473,3 @@ test("downloads a file over direct TCP with its original filename and bytes", as
expect(await downloaded.failure()).toBeNull();
expect(await readFile(savedFile)).toEqual(source);
});
test("downloads an unsupported binary from its preview with original filename and bytes", async ({
page,
}, testInfo) => {
const fileName = "rapport données.bin";
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openFileFromExplorer(page, fileName);
const downloaded = await downloadFileFromPreview(page);
expect(downloaded.suggestedFilename()).toBe(fileName);
const savedFile = testInfo.outputPath(fileName);
await downloaded.saveAs(savedFile);
expect(await downloaded.failure()).toBeNull();
expect(await readFile(savedFile)).toEqual(source);
});
@@ -125,15 +125,12 @@ test.describe("Tab creation", () => {
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertNewTabMenuTriggerVisible(page);
await pressNewTabShortcut(page);
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
// The workspace opens on a New tab of its own; count from the tab the shortcut adds.
await expect(newTabs.first()).toBeVisible();
const openedCount = await newTabs.count();
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(openedCount + 1);
const countBefore = openedCount + 1;
const countBefore = await newTabs.count();
const draftCount = await countTabsOfKind(page, "draft");
await test.step("opening the plus menu leaves the current tabs intact", async () => {
@@ -160,22 +160,6 @@ test.describe("New workspace entry points", () => {
}
});
test("Ctrl+N opens the project picker with search focused", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await openGlobalNewWorkspaceComposer(page);
await page.goBack();
await openNewWorkspaceProjectPickerWithShortcut(page, "Control+N");
} finally {
await seeded.cleanup();
}
});
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
page,
}) => {
@@ -5,10 +5,6 @@ import path from "node:path";
import { expect, test } from "../support/fixtures";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { openSettingsSection } from "../support/helpers/settings";
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { contrastRatio } from "../../src/utils/color";
import type { Locator, Page } from "@playwright/test";
const PLUGIN_ID = "plugin-theme-e2e";
@@ -44,16 +40,6 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
ring: "#9ca0b0",
},
});
for (const [id, name, overrides] of [
["solarized-dark", "Solarized Dark", {}],
["solarized-dark-custom", "Solarized Dark Custom", { sidebarSelected: "#334455", foregroundExtraMuted: "#ddeeff" }],
]) {
plugin.addTheme({ id, name, appearance: "dark", colors: {
background: "#002b36", foreground: "#839496", raised: "#073642",
control: "#073642", border: "#073642", accent: "#268bd2",
mutedForeground: "#586e75", ring: "#93a1a1", ...overrides,
}});
}
return () => {};
}`;
@@ -61,88 +47,6 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
// contributed palette reached the semantic tokens rather than just the swatch.
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
const LATTE_RING = "rgb(156, 160, 176)";
async function selectTheme(page: Page, name: string) {
await page.goto("/settings");
await openSettingsSection(page, "appearance");
const theme = page.getByRole("radio", { name, exact: true });
await expect(theme).toBeVisible({ timeout: 30_000 });
await theme.click();
await expect(theme).toBeChecked();
}
async function expectTimestampContrast(row: Locator) {
const timestamp = row.getByTestId("sidebar-workspace-timestamp");
await expect(timestamp).toBeVisible();
const hex = (rgb: string) =>
`#${rgb
.match(/\d+/g)!
.slice(0, 3)
.map((value) => Number(value).toString(16).padStart(2, "0"))
.join("")}`;
const background = await row.evaluate((element) => getComputedStyle(element).backgroundColor);
const foreground = await timestamp.evaluate((element) => getComputedStyle(element).color);
expect(contrastRatio(hex(background), hex(foreground))).toBeGreaterThanOrEqual(2.5);
}
test("keeps plugin workspace timestamps readable on state surfaces and honors overrides", async ({
page,
}) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-state-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
const selected = await seedMockAgentWorkspace({
repoPrefix: "theme-selected-",
title: "Selected theme workspace",
});
const hovered = await seedMockAgentWorkspace({
repoPrefix: "theme-hovered-",
title: "Hovered theme workspace",
});
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await selectTheme(page, "Solarized Dark");
await openAgentRoute(page, selected);
const selectedRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${selected.workspaceId}`,
);
const hoveredRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${hovered.workspaceId}`,
);
await expect(selectedRow).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
await page.mouse.move(0, 0);
await expect(selectedRow).toHaveCSS("background-color", "rgb(33, 58, 66)");
await expectTimestampContrast(selectedRow);
await hoveredRow.hover();
await expect(hoveredRow).toHaveCSS("background-color", "rgb(19, 50, 60)");
await expectTimestampContrast(hoveredRow);
await selectTheme(page, "Solarized Dark Custom");
await openAgentRoute(page, selected);
await page.mouse.move(0, 0);
await expect(selectedRow).toHaveCSS("background-color", "rgb(51, 68, 85)");
await expect(selectedRow.getByTestId("sidebar-workspace-timestamp")).toHaveCSS(
"color",
"rgb(221, 238, 255)",
);
} finally {
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close();
await selected.cleanup();
await hovered.cleanup();
await rm(directory, { recursive: true, force: true });
}
});
test("applies a contributed theme and falls back when its plugin is gone", async ({
page,
@@ -179,13 +83,6 @@ test("applies a contributed theme and falls back when its plugin is gone", async
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
});
await test.step("keyboard focus draws the outline in the theme's ring color", async () => {
await page.getByRole("radio", { name: "Catppuccin Latte", exact: true }).click();
await page.mouse.move(0, 0);
await page.keyboard.press("Tab");
await expect(page.locator(":focus-visible").first()).toHaveCSS("outline-color", LATTE_RING);
});
await mochaItem.click();
await expect(mochaItem).toBeChecked();
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
@@ -1,148 +0,0 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
closeSidebarDisplayPreferences,
openMobileAgentSidebar,
openSidebarDisplayPage,
} from "../support/helpers/sidebar";
const PLUGIN_ID = "row-badges-e2e";
const TITLE = "Badge host workspace";
const PLUGIN_SOURCE = `import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
function Review() {
return <WorkspaceRowBadge icon="Eye" label="In review" tone="warning" />;
}
function Docs() {
return <WorkspaceRowBadge icon="BookOpen" label="Docs" />;
}
function IconOnly() {
return <WorkspaceRowBadge icon="Star" />;
}
function EmptyLabel() {
return <WorkspaceRowBadge icon="Circle" label="" />;
}
function Quiet() {
return null;
}
function Broken() {
throw new Error("badge failed");
}
export default function contribute(client) {
client.addWorkspaceRowBadgeItem({ id: "review", title: "Review state", icon: "Eye", Component: Review });
client.addWorkspaceRowBadgeItem({ id: "docs", title: "Docs state", icon: "BookOpen", Component: Docs });
client.addWorkspaceRowBadgeItem({ id: "icon", title: "Icon state", icon: "Star", Component: IconOnly });
client.addWorkspaceRowBadgeItem({ id: "empty", title: "Empty label state", icon: "Circle", Component: EmptyLabel });
client.addWorkspaceRowBadgeItem({ id: "quiet", title: "Quiet state", icon: "Moon", Component: Quiet });
client.addWorkspaceRowBadgeItem({ id: "broken", title: "Broken state", icon: "Bug", Component: Broken });
return () => {};
}
`;
// The meta line the badges draw on. Each item sits in one wrapper of its own, so the line is two up.
const LABELS = { review: "In review", docs: "Docs" } as const;
function badgeLine(page: Page, itemId: keyof typeof LABELS) {
return badge(page, itemId).locator("xpath=../..");
}
function badge(page: Page, itemId: keyof typeof LABELS) {
return page.getByLabel(LABELS[itemId], { exact: true }).locator("visible=true");
}
async function toggleShowItem(page: Page, label: string, touch: boolean) {
await openSidebarDisplayPage(page, "sidebar-display-show");
await page.getByRole("menuitem", { name: label, exact: true }).click();
if (touch) {
await page
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
.click({ position: { x: 5, y: 5 } });
} else {
await closeSidebarDisplayPreferences(page);
}
}
test("a plugin badge shows on workspace rows, hides quietly, and follows its Show toggle", async ({
page,
}, info) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({ repoPrefix: "row-badges-", title: TITLE });
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-row-badges-"));
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoAppShell(page);
await test.step("the badge draws on the row; a null item and a throwing item draw nothing", async () => {
await expect(badge(page, "review")).toBeVisible();
await expect(badge(page, "review")).toContainText("In review");
// The null and throwing items after them draw nothing, and the line adds nothing of its own.
await expect(badgeLine(page, "review")).toHaveText("In reviewDocs");
// An icon-only badge is named by its item's title.
await expect(
page.getByLabel("Icon state", { exact: true }).locator("visible=true"),
).toBeVisible();
await expect(
page.getByLabel("Empty label state", { exact: true }).locator("visible=true"),
).toBeVisible();
await expect(page.getByText(TITLE).first()).toBeVisible();
await page.screenshot({ path: info.outputPath("wide.png") });
});
await test.step("every item has a Show toggle, and switching one off removes only that badge", async () => {
await openSidebarDisplayPage(page, "sidebar-display-show");
for (const label of ["Review state", "Quiet state", "Broken state", "Docs state"]) {
await expect(page.getByRole("menuitem", { name: label, exact: true })).toBeVisible();
}
await expect(page.getByTestId("sidebar-plugin-row-badge-items-separator")).toBeVisible();
await page.screenshot({ path: info.outputPath("show-menu.png") });
await page.getByRole("menuitem", { name: "Review state", exact: true }).click();
await closeSidebarDisplayPreferences(page);
await expect(badge(page, "review")).toHaveCount(0);
// The first badge is gone, so the next one leads the line without a "·".
await expect(badgeLine(page, "docs")).toHaveText("Docs");
await toggleShowItem(page, "Review state", false);
await expect(badge(page, "review")).toBeVisible();
});
await test.step("the badge draws on a compact layout", async () => {
await page.setViewportSize({ width: 390, height: 844 });
await openMobileAgentSidebar(page);
await expect(badge(page, "review")).toBeVisible();
await page.screenshot({ path: info.outputPath("compact.png") });
await toggleShowItem(page, "Review state", true);
await expect(badge(page, "review")).toHaveCount(0);
await toggleShowItem(page, "Review state", true);
await expect(badge(page, "review")).toBeVisible();
});
} finally {
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
});
@@ -1,4 +1,3 @@
import { writeFile } from "node:fs/promises";
import { test, expect } from "../support/fixtures";
import {
buildHostWorkspaceRoute,
@@ -7,31 +6,20 @@ import {
buildSettingsRoute,
buildSettingsSectionRoute,
} from "@/utils/host-routes";
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
closeCompactSettings,
captureCompactSidebarReturn,
openSettingsFromCompactSidebar,
captureCompactSidebarContext,
expectCompactSidebarOpen,
closeCompactSidebar,
scrollSettingsSidebarToEnd,
revealSettingsSidebarSection,
expectSettingsSidebarScrollSettled,
scrollSettingsSidebarUp,
changeSettingsInterfaceFontSize,
selectSettingsHostSectionsRapidly,
expectSettingsSidebarScrollOffset,
scrollSettingsDetailDown,
expectSettingsDetailScrollOffset,
openSettingsSection,
changeSettingsSyntaxTheme,
revealSettingsSyntaxTheme,
expectSettingsSyntaxColorChanged,
changeSettingsTypography,
normalizeSettingsFontSizes,
changeSettingsInterfaceFontSizeKeepingSidebarPosition,
expectSettingsHeader,
openAddHostFlow,
selectHostConnectionType,
@@ -96,32 +84,13 @@ test.describe("Settings sidebar scroll position", () => {
await expectSettingsSidebarScrollOffset(page, 0);
});
test("updates appearance without losing the detail scroll or form edits", async ({ page }) => {
test("keeps the sidebar position through interface font changes", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
const { offset, keywordColor } = await revealSettingsSyntaxTheme(page);
await changeSettingsSyntaxTheme(page, "Dracula");
await expectSettingsDetailScrollOffset(page, offset);
await expectSettingsSyntaxColorChanged(page, keywordColor);
await changeSettingsTypography(page);
});
test("shows normalized font sizes after committing out-of-range and empty values", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
await normalizeSettingsFontSizes(page);
});
test("keeps the sidebar context through interface font changes", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
await scrollSettingsSidebarToEnd(page);
const offset = await changeSettingsInterfaceFontSizeKeepingSidebarPosition(page, 20);
const offset = await scrollSettingsSidebarToEnd(page);
await changeSettingsInterfaceFontSize(page, 20);
await expectSettingsSidebarScrollOffset(page, offset);
await openSettingsHostSection(page, getServerId(), "providers");
await expectSettingsSidebarScrollOffset(page, offset);
});
@@ -381,87 +350,6 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("returning from Settings preserves the open sidebar from the first paint", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "settings-sidebar-continuity-" });
for (let i = 0; i < 12; i++) await withWorkspace({ prefix: "settings-sidebar-scroll-" });
await openWorkspace(page, workspace);
await openCompactSidebar(page);
await expectCompactSidebarOpen(page);
const expectContextPreserved = await captureCompactSidebarContext(page);
const openingFrames = await captureCompactSidebarReturn(page, () =>
openSettingsFromCompactSidebar(page),
);
await writeFile(
testInfo.outputPath("sidebar-opening-frames.json"),
JSON.stringify(openingFrames, null, 2),
);
expect(openingFrames.some((frame) => frame.settingsVisible)).toBe(true);
expect(openingFrames.every((frame) => frame.sidebarVisible || frame.settingsVisible)).toBe(
true,
);
await openSettingsSection(page, "appearance");
await goBackInSettings(page);
const frames = await captureCompactSidebarReturn(page, () => closeCompactSettings(page));
await writeFile(
testInfo.outputPath("sidebar-return-frames.json"),
JSON.stringify(frames, null, 2),
);
await testInfo.attach("sidebar-return-frames", {
body: JSON.stringify(frames, null, 2),
contentType: "application/json",
});
const workspaceFrames = frames.filter((frame) => !frame.pathname.startsWith("/settings"));
expect(workspaceFrames.length).toBeGreaterThan(0);
expect(workspaceFrames.every((frame) => frame.sidebarVisible)).toBe(true);
const firstX = workspaceFrames[0]!.closeX;
expect(
workspaceFrames.every((frame) => Math.abs((frame.closeX ?? Infinity) - firstX!) < 1),
).toBe(true);
await expectCompactSidebarOpen(page);
await expectContextPreserved();
await openSettingsFromCompactSidebar(page);
const backFrames = await captureCompactSidebarReturn(page, async () => {
await page.goBack();
});
await writeFile(
testInfo.outputPath("browser-back-frames.json"),
JSON.stringify(backFrames, null, 2),
);
await testInfo.attach("browser-back-frames", {
body: JSON.stringify(backFrames, null, 2),
contentType: "application/json",
});
const backWorkspaceFrames = backFrames.filter(
(frame) => !frame.pathname.startsWith("/settings"),
);
expect(backWorkspaceFrames.length).toBeGreaterThan(0);
expect(backWorkspaceFrames.every((frame) => frame.sidebarVisible)).toBe(true);
await expectCompactSidebarOpen(page);
await closeCompactSidebar(page);
});
test("widening during Settings discards the mobile sidebar return", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "settings-sidebar-resize-" });
await openWorkspace(page, workspace);
await openCompactSettings(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await page.setViewportSize({ width: 1000, height: 844 });
await expect(page.getByTestId("settings-detail-pane")).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await goBackInSettings(page);
await expectCompactSettingsList(page);
await closeCompactSettings(page);
await expect(page.getByRole("button", { name: "Close sidebar", exact: true })).toBeHidden();
await expect(
page.getByRole("button", { name: "Open menu", exact: true }).first(),
).toBeInViewport();
});
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-tab-switcher-",
@@ -489,7 +377,6 @@ test.describe("Settings — compact master-detail", () => {
await expect(newAgent).toHaveCount(0);
await closeCompactSettings(page);
await closeCompactSidebar(page);
await expect(switcher).toBeVisible();
await expect(newAgent).toHaveCount(0);
await switcher.click();
@@ -16,6 +16,7 @@ import {
pinWorkspaceFromSidebar,
scrollWorkspaceList,
sweepPointerAcrossWorkspaceRows,
watchWorkspaceHoverCard,
} from "../support/helpers/sidebar";
import { seedWorkspace } from "../support/helpers/seed-client";
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
@@ -543,6 +544,29 @@ test.describe("Sidebar workspace list", () => {
});
});
// On a wide touch screen (iPad) a tap is not a hover. Opening the card on the tap changes the page
// under the finger, and iPad Safari then drops the tap instead of opening the workspace.
test.describe("Sidebar workspace rows on a touch screen", () => {
test.use({ hasTouch: true, viewport: { width: 1180, height: 820 } });
test("tapping a workspace row opens it without the hover card", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-touch-tap-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
const hoverCard = await watchWorkspaceHoverCard(page);
await row.tap();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await expectWorkspaceHoverCardClosed(page);
// A card that opens during the tap is what makes iPad Safari drop the tap.
expect(await hoverCard.hasOpened()).toBe(false);
} finally {
await workspace.cleanup();
}
});
});
test.describe("Mobile sidebar panelState transition", () => {
test.use({ viewport: { width: 390, height: 844 } });
+19 -145
View File
@@ -8,7 +8,6 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { openSettingsSection } from "../support/helpers/settings";
import { gate } from "../support/helpers/context-window";
import { getServerId } from "../support/helpers/server-id";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import {
@@ -26,7 +25,6 @@ import {
} from "../support/helpers/usage-login";
import {
claudeAndCodexReports,
closeUsage,
expectPinnedUsage,
openCompactSidebar,
openUsageFromIcon,
@@ -34,12 +32,7 @@ import {
pinRow,
openUsageOptions,
closeUsageOptions,
pickUsageHost,
usageModalHost,
dragUsageCardAbove,
expectUsageCardOrder,
expectUsageOptionsFitContent,
setUsageCardOn,
showUsageAs,
refreshAllUsage,
togglePin,
@@ -86,17 +79,16 @@ function twoHoursAgo(): string {
}
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
async function qaScreenshot(page: Page, name: string, target: "page" | "summary" = "page") {
async function qaScreenshot(page: Page, name: string) {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
await page.waitForTimeout(600);
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
const file = path.join(directory, `${name}.png`);
if (target === "summary") {
await usageItem(page).screenshot({ path: file });
return;
}
await page.screenshot({ path: file });
await page.screenshot({ path: path.join(directory, `${name}.png`) });
}
function hostFilter(page: Page) {
return page.locator('[data-testid="usage-host-filter-trigger"]:visible');
}
function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
@@ -110,14 +102,6 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
};
}
function accountReport(sourceId: string, account: string, usedPct: number): UsageReportEntry {
return {
...weeklyReport(sourceId, usedPct),
id: `${sourceId}:${account}`,
account: { label: account },
};
}
function installReversedUsageRefresh(page: Page) {
const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)];
const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)];
@@ -130,15 +114,15 @@ async function openUsageSummaryAtWidth(page: Page, width: number) {
await page.setViewportSize({ width, height: 900 });
await gotoAppShell(page);
if (width === 390) await openCompactSidebar(page);
await expectPinnedUsage(page, ["12% Weekly", "31% Weekly"]);
await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]);
await openUsageFromItem(page);
}
async function expectUsageProviderOrder(page: Page, summary: string[]) {
const group = page.getByTestId(`usage-host-${getServerId()}`);
await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([
"openai plan",
"opencode plan",
"openai plan",
]);
await expectPinnedUsage(page, summary);
}
@@ -153,7 +137,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
}
test.describe("usage modal", () => {
test("opens from the sidebar on the host's reports, with no host named for one host", async ({
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
page,
}) => {
test.setTimeout(120_000);
@@ -193,7 +177,7 @@ test.describe("usage modal", () => {
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("usage-modal-host")).toHaveCount(0);
await expect(hostFilter(page)).toHaveCount(0);
await qaScreenshot(page, "usage-modal-one-host");
await expect(group.getByText("31% used")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
@@ -210,61 +194,15 @@ test.describe("usage modal", () => {
}) => {
const usage = await installReversedUsageRefresh(page);
await openUsageSummaryAtWidth(page, width);
// Reports arrive opencode first; cards and the summary stay alphabetical.
await expectUsageProviderOrder(page, ["12% Weekly", "31% Weekly"]);
await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]);
await qaScreenshot(page, `usage-order-${width}-before-refresh`);
await refreshUsageAndWaitForReports(page, usage);
await expectUsageProviderOrder(page, ["22% Weekly", "41% Weekly"]);
await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]);
await qaScreenshot(page, `usage-order-${width}-after-refresh`);
});
}
test("keeps a card turned off and a dragged order after a reload", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, {
lists: [
[
accountReport("beta", "work", 12),
weeklyReport("alpha", 31),
accountReport("beta", "home", 20),
],
],
});
await gotoAppShell(page);
// Three windows leave no room for labels in the summary, so it reads percents.
await expectPinnedUsage(page, ["31%", "20%", "12%"]);
await openUsageFromItem(page);
await expectUsageCardOrder(page, ["alpha:a", "beta:home", "beta:work"]);
// Without the pointer over the card, keyboard focus on its handle shows the handle.
await page.mouse.move(0, 0);
const alphaHandle = page
.getByTestId("usage-report-alpha:a")
.getByRole("button", { name: "Reorder alpha plan", exact: true });
await expect(alphaHandle).toHaveCSS("opacity", "0");
await alphaHandle.focus();
await expect(alphaHandle).toHaveCSS("opacity", "1");
await alphaHandle.blur();
// The off card collapses to its header, which still names the account.
await setUsageCardOn(page, "beta plan (work)", false);
const work = page.getByTestId("usage-report-beta:work");
await expect(work.getByText("12% used")).toHaveCount(0);
await expect(work.getByText("work", { exact: true })).toBeVisible();
await expectPinnedUsage(page, ["31% Weekly", "20% Weekly"]);
await dragUsageCardAbove(page, "beta:home", "alpha:a");
await expectUsageCardOrder(page, ["beta:home", "alpha:a", "beta:work"]);
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
await page.reload();
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
await openUsageFromItem(page);
await expectUsageCardOrder(page, ["beta:home", "alpha:a", "beta:work"]);
await expect(page.getByTestId("usage-report-beta:work").getByText("12% used")).toHaveCount(0);
});
test("refreshes one report from its card", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
@@ -376,11 +314,9 @@ test.describe("usage modal", () => {
);
try {
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
// The second host's reports wait until the test lets them through, like a slow host.
const secondaryReports = gate();
await installUsageReportsFixture(page, {
port: secondary.port,
lists: [{ stream: [secondaryReports.promise, weeklyReport("beta", 12)] }],
lists: [[weeklyReport("beta", 12)]],
});
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
@@ -394,15 +330,12 @@ test.describe("usage modal", () => {
await openUsageFromItem(page);
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
await expect(usageModalHost(page)).toHaveText("localhost");
await pickUsageHost(page, secondary.serverId);
// Until its reports arrive, the picked host loads; it never shows the first host's cards.
const secondaryBody = page.getByTestId(`usage-host-${secondary.serverId}`);
await expect(secondaryBody.getByText("Loading usage...")).toBeVisible();
await expect(page.getByTestId("usage-modal-body").getByText("31% used")).toHaveCount(0);
await expect(usageModalHost(page)).toHaveText("Secondary box");
secondaryReports.open();
await expect(secondaryBody.getByText("12% used")).toBeVisible({ timeout: 30_000 });
await hostFilter(page).click();
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
await qaScreenshot(page, "usage-modal-picked-host");
await expectPinnedUsage(page, ["12% Weekly"]);
@@ -421,51 +354,6 @@ test.describe("usage modal", () => {
await secondary.close().catch(() => undefined);
}
});
test("on a phone, picking a host in Settings keeps Usage open on that host", async ({ page }) => {
test.setTimeout(420_000);
const longName = "build-box-eu-west-gpu-runner-02.internal";
const secondary = await startIsolatedHostDaemon(
`srv_usage_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
);
try {
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
await installUsageReportsFixture(page, {
port: secondary.port,
lists: [[weeklyReport("beta", 12)]],
});
await page.setViewportSize({ width: 375, height: 812 });
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondary.serverId,
label: longName,
port: secondary.port,
});
await openCompactSidebar(page);
await openUsageFromItem(page);
await pickUsageHost(page, secondary.serverId);
// The sheet stays up once Settings has closed over it, showing the picked host's cards.
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
await expect(usageModalHost(page)).toHaveText(longName);
// The long name truncates under the title; the title itself reads in full.
const title = page.locator('[data-testid="usage-modal-title"]:visible');
await expect(title).toHaveText("Usage");
expect(await title.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
true,
);
const host = usageModalHost(page);
expect(await host.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(
true,
);
await qaScreenshot(page, "usage-phone-picked-long-host");
} finally {
await secondary.close().catch(() => undefined);
}
});
});
const loginWindows: UsageReportEntry["report"] = {
@@ -740,15 +628,6 @@ for (const compact of [false, true]) {
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await expectPinnedUsage(page, ["42% Weekly"]);
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toBeVisible();
await expect(usageItem(page)).toHaveAccessibleName(/Unable to refresh usage/);
await qaScreenshot(page, `usage-error-detail-${compact ? "compact" : "desktop"}`);
await closeUsage(page);
await qaScreenshot(page, `usage-error-summary-${compact ? "compact" : "desktop"}`, "summary");
await openUsageFromItem(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
@@ -760,8 +639,6 @@ for (const compact of [false, true]) {
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: No active quota");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await expectPinnedUsage(page, ["42% Weekly"]);
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
await fixture.setReport({
status: "available",
@@ -770,9 +647,6 @@ for (const compact of [false, true]) {
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "43% used");
await expectPinnedUsage(page, ["43% Weekly"]);
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
await expect(usageItem(page)).not.toHaveAccessibleName(/Unable to refresh usage/);
await advanceUsageClock(page, 60_000);
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
@@ -29,7 +29,6 @@ import {
openCompactSidebar,
openUsageFromIcon,
openUsageFromItem,
openUsageFromItemWithPressFeedback,
pinRow,
scrollUsageToWindow,
setSummaryInSidebar,
@@ -198,7 +197,7 @@ test.describe("Usage item", () => {
});
await test.step("the summary opens the same sheet", async () => {
await openUsageFromItemWithPressFeedback(page);
await openUsageFromItem(page);
await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
await expect(page).toHaveURL(startingUrl);
await qaScreenshot(page, "compact-usage-summary-sheet");
@@ -318,11 +317,10 @@ test.describe("Usage item", () => {
await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => {
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
// One host: Settings has no Host row, and the title no host under it.
await expect(page.getByTestId("usage-modal-host")).toHaveCount(0);
await openUsageOptions(page);
await expect(page.getByTestId("usage-options-host")).toHaveCount(0);
await closeUsageOptions(page);
// One host: no host filter, as on History.
await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount(
0,
);
await togglePin(screen, "Claude", "Weekly");
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
@@ -136,7 +136,7 @@ async function expectForkFailureWithoutOverlappingStatus(page: Page) {
async function observeToastReplacement(page: Page) {
const observation = await page
.getByRole("alert")
.filter({ hasText: "Reconnecting" })
.filter({ hasText: "Reconnecting to host" })
.evaluateHandle((toast) => {
const frames: Array<{ connected: boolean; opacity: number; transform: string }> = [];
let frame = 0;
@@ -14,7 +14,6 @@ import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
import {
archivedToast,
archiveFromRowButton,
createGatedTeardown,
expectDirectoryPresence,
@@ -26,7 +25,6 @@ import {
resizeSidebarToMinimum,
rowArchiveButton,
rowKebab,
scrollSidebarListToEnd,
undoButton,
workspaceRow,
workspaceTitleWidth,
@@ -121,41 +119,6 @@ test.describe("Workspace archive undo", () => {
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
});
test("a long list ends just below its last row, and the toast over it moves no row", async ({
page,
}) => {
await page.clock.install({ time: Date.now() });
const fillers: Array<{ path: string; cleanup: () => Promise<void> }> = [];
const fillerProjectIds: string[] = [];
try {
for (let index = 0; index < 10; index += 1) {
const repo = await createTempGitRepo(`archive-toast-filler-${index}-`);
fillers.push(repo);
fillerProjectIds.push((await openProjectViaDaemon(client, repo.path)).projectId);
}
const worktree = await seedWorktree(page, "archive-toast-long-list");
const resting = await scrollSidebarListToEnd(page);
expect(resting.listBottom - resting.rowBottoms.at(-1)!).toBeLessThan(resting.rowHeight);
await archiveFromRowButton(page, worktree.workspaceId);
await page.mouse.move(0, 0);
const withToast = await scrollSidebarListToEnd(page);
const toastBox = await archivedToast(page).boundingBox();
if (!toastBox) throw new Error("Toast is not laid out");
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(withToast.listBottom);
await outlastUndoWindow(page);
await expect(archivedToast(page)).toHaveCount(0);
expect(await scrollSidebarListToEnd(page)).toEqual(withToast);
} finally {
for (const projectId of fillerProjectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
for (const repo of fillers) await repo.cleanup().catch(() => undefined);
}
});
test("an archive that fails after the sidebar was replaced still shows its error", async ({
page,
}) => {
@@ -1,32 +0,0 @@
import { expect, test } from "../support/fixtures";
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
// The browser owns Cmd+Shift+brackets for its own tabs, so web keeps
// Alt+Shift+brackets on every OS. Desktop bindings live in the desktop suite.
test("web switches to the previous and next tab with Alt+Shift+[ and ]", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "tab-relative-web-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const terminalTabs = page
.locator('[data-testid^="workspace-tab-terminal_"]')
.filter({ visible: true });
await clickNewTerminal(page);
await expect(terminalTabs).toHaveCount(1, { timeout: 30_000 });
await clickNewTerminal(page);
await expect(terminalTabs).toHaveCount(2, { timeout: 30_000 });
const first = terminalTabs.nth(0);
const second = terminalTabs.nth(1);
await expect(second).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Alt+Shift+BracketLeft");
await expect(first).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Alt+Shift+BracketRight");
await expect(second).toHaveAttribute("aria-selected", "true");
} finally {
await workspace.cleanup();
}
});
@@ -5,34 +5,27 @@ import path from "node:path";
import type { Dialog, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { getServerId } from "../support/helpers/server-id";
import {
clickArchiveWorkspaceMenuItem,
expectWorkspaceAbsentFromSidebar,
} from "../support/helpers/sidebar";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
rowArchiveButton,
undoButton,
workspaceRow,
} from "../support/helpers/workspace-archive-undo";
import {
waitForSidebarHydration,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
async function seedRiskyWorktree(client: DaemonClient, worktreeDirectory: string): Promise<void> {
async function seedRiskyWorktree(
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
worktreeDirectory: string,
): Promise<void> {
// The daemon only reports unpushed commits when the branch has a configured
// upstream (aheadOfOrigin is computed against `branch.<name>.merge`). Push the
// worktree branch at its current head first so it tracks origin with 0 ahead,
@@ -62,189 +55,35 @@ async function seedRiskyWorktree(client: DaemonClient, worktreeDirectory: string
const dirtyFile = path.join(worktreeDirectory, "DIRTY.md");
await writeFile(dirtyFile, "# dirty\n");
await refreshCheckout(client, worktreeDirectory);
}
async function refreshCheckout(client: DaemonClient, directory: string): Promise<void> {
const refreshed = await client.checkoutRefresh(directory);
const refreshed = await client.checkoutRefresh(worktreeDirectory);
if (!refreshed.success) {
throw new Error(`Failed to refresh checkout for ${directory}`);
throw new Error(`Failed to refresh checkout for ${worktreeDirectory}`);
}
}
// The archive confirmation is a synchronous web `window.confirm()` that opens once the app has
// read the worktree's git state from the host. Wait for the dialog itself: on web the click does
// not resolve until it is answered, so answer it from the event.
async function answerArchiveWarning(
// The archive confirmation is a synchronous web `window.confirm()`. The click that
// opens it does not resolve until the dialog is answered, so the handler must
// accept/dismiss inline — awaiting the dialog only *after* the click deadlocks, as
// the click waits for an answer that is gated behind that same click.
async function clickArchiveAndAnswerWarning(
page: Page,
workspaceId: string,
answer: "accept" | "dismiss",
openArchive: () => Promise<void>,
): Promise<Dialog> {
const warning = page.waitForEvent("dialog", { timeout: 10_000 }).then(async (dialog) => {
await (answer === "accept" ? dialog.accept() : dialog.dismiss());
return dialog;
let warning: Dialog | undefined;
page.once("dialog", (dialog) => {
warning = dialog;
void (answer === "accept" ? dialog.accept() : dialog.dismiss());
});
await openArchive();
return await warning;
}
// A clean Paseo worktree's primary git action in the workspace header is Archive.
function gitActionsArchiveButton(page: Page) {
return page.getByTestId("changes-primary-cta").and(page.getByLabel("Archive workspace"));
}
async function clickRowArchiveButton(page: Page, workspaceId: string): Promise<void> {
await workspaceRow(page, workspaceId).hover();
await rowArchiveButton(page, workspaceId).click();
}
interface SessionEnvelope {
type?: unknown;
message?: {
type?: unknown;
workspaceId?: unknown;
cwd?: unknown;
payload?: Record<string, unknown>;
};
}
function parseSessionEnvelope(message: string): SessionEnvelope | null {
try {
return JSON.parse(message) as SessionEnvelope;
} catch {
return null;
await clickArchiveWorkspaceMenuItem(page, workspaceId);
if (!warning) {
throw new Error("Expected an archive confirmation dialog, but none was shown.");
}
}
function removeGitStatusFromDescriptors(value: unknown, workspaceId: string): number {
if (Array.isArray(value)) {
return value.reduce<number>(
(count, item) => count + removeGitStatusFromDescriptors(item, workspaceId),
0,
);
}
if (!value || typeof value !== "object") return 0;
const record = value as Record<string, unknown>;
let count = 0;
if (record.id === workspaceId && "workspaceDirectory" in record) {
delete record.gitRuntime;
record.diffStat = null;
count += 1;
}
for (const child of Object.values(record)) {
count += removeGitStatusFromDescriptors(child, workspaceId);
}
return count;
}
/**
* Relays the app's connection to the daemon. Every workspace descriptor for `workspaceId` reaches
* the app without git status, as the host sends it before its first checkout snapshot of the
* worktree, so the sidebar row has nothing cached to warn from. `failCheckoutStatusFor` turns the
* host's checkout status answer for that directory into a git error.
*/
async function relayDaemon(
page: Page,
input: { workspaceId: string; failCheckoutStatusFor?: string },
) {
let descriptorRewrites = 0;
let archiveRequests = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
if (typeof message === "string") {
const request = parseSessionEnvelope(message)?.message;
if (
request?.type === "archive_workspace_request" &&
request.workspaceId === input.workspaceId
) {
archiveRequests += 1;
}
}
server.send(message);
});
server.onMessage((message) => {
if (typeof message !== "string") {
browser.send(message);
return;
}
const envelope = parseSessionEnvelope(message);
const response = envelope?.message;
if (
input.failCheckoutStatusFor &&
response?.type === "checkout_status_response" &&
response.payload?.cwd === input.failCheckoutStatusFor
) {
response.payload = {
...response.payload,
isGit: false,
isPaseoOwnedWorktree: false,
repoRoot: null,
currentBranch: null,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
remoteUrl: null,
error: { code: "UNKNOWN", message: "git status failed" },
};
browser.send(JSON.stringify(envelope));
return;
}
if (!envelope || !message.includes(input.workspaceId)) {
browser.send(message);
return;
}
descriptorRewrites += removeGitStatusFromDescriptors(envelope, input.workspaceId);
browser.send(JSON.stringify(envelope));
});
});
return {
descriptorRewrites: () => descriptorRewrites,
archiveRequests: () => archiveRequests,
};
}
/**
* Holds the app's next checkout status request for `directory` once armed, so a test can act
* while the archive waits for the host's answer.
*/
async function holdCheckoutStatusRequest(page: Page, directory: string) {
let armed = false;
let held: (() => void) | null = null;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const request = typeof message === "string" ? parseSessionEnvelope(message)?.message : null;
if (
armed &&
!held &&
request?.type === "checkout_status_request" &&
request.cwd === directory
) {
held = () => server.send(message);
return;
}
server.send(message);
});
server.onMessage((message) => browser.send(message));
});
return {
arm: () => {
armed = true;
},
waitForHeld: () => expect.poll(() => held !== null).toBe(true),
release: () => {
if (!held) throw new Error("No checkout status request is held");
held();
},
};
return warning;
}
test.describe("Workspace archive risk warning for worktree backing", () => {
let client: DaemonClient;
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -264,53 +103,36 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
async function createWorktree(slug: string) {
const project = await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `${slug}-${Date.now()}`,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
return { ...worktree, project };
}
async function openSidebarRow(page: Page, workspaceId: string): Promise<void> {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
}
async function expectWorktreeKept(page: Page, worktree: { workspaceId: string }) {
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 10_000 });
}
async function hostListsWorkspace(workspaceId: string): Promise<boolean> {
const { entries } = await client.fetchWorkspaces();
return entries.some((entry) => entry.id === workspaceId);
}
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
page,
}) => {
const worktree = await createWorktree("archive-risk");
await seedRiskyWorktree(client, worktree.workspaceDirectory);
await openSidebarRow(page, worktree.workspaceId);
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `archive-risk-${Date.now()}`,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
const firstWarning = await answerArchiveWarning(page, "dismiss", () =>
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
);
await seedRiskyWorktree(client, worktree.workspaceDirectory);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
const firstWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "dismiss");
expect(firstWarning.type()).toBe("confirm");
expect(firstWarning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(firstWarning.message()).toContain("Uncommitted changes");
expect(firstWarning.message()).toContain("1 unpushed commit");
await expectWorktreeKept(page, worktree);
await expect(
page.getByTestId(`sidebar-workspace-row-${serverId}:${worktree.workspaceId}`),
).toBeVisible({ timeout: 10_000 });
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
const secondWarning = await answerArchiveWarning(page, "accept", () =>
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
);
const secondWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "accept");
expect(secondWarning.message()).toContain("Uncommitted changes");
expect(secondWarning.message()).toContain("1 unpushed commit");
@@ -321,128 +143,4 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
createdWorktreeDirectories.delete(worktree.workspaceDirectory);
});
test("the row's archive button warns from the host's git state while the row has none, and Cancel keeps the worktree", async ({
page,
}) => {
const worktree = await createWorktree("archive-risk-uncached");
await seedRiskyWorktree(client, worktree.workspaceDirectory);
const relay = await relayDaemon(page, { workspaceId: worktree.workspaceId });
await openSidebarRow(page, worktree.workspaceId);
expect(relay.descriptorRewrites()).toBeGreaterThan(0);
const warning = await answerArchiveWarning(page, "dismiss", () =>
clickRowArchiveButton(page, worktree.workspaceId),
);
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(warning.message()).toContain("Uncommitted changes");
expect(warning.message()).toContain("1 unpushed commit");
await expectWorktreeKept(page, worktree);
expect(relay.archiveRequests()).toBe(0);
expect(existsSync(path.join(worktree.workspaceDirectory, "DIRTY.md"))).toBe(true);
});
test("a worktree holding only new files warns when the repository hides untracked files", async ({
page,
}) => {
execSync("git config status.showUntrackedFiles no", { cwd: tempRepo.path, stdio: "ignore" });
const worktree = await createWorktree("archive-risk-untracked");
await writeFile(path.join(worktree.workspaceDirectory, "NEW.md"), "# not yet added\n");
await refreshCheckout(client, worktree.workspaceDirectory);
await openSidebarRow(page, worktree.workspaceId);
const warning = await answerArchiveWarning(page, "dismiss", () =>
clickRowArchiveButton(page, worktree.workspaceId),
);
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(warning.message()).toContain("Uncommitted changes");
await expectWorktreeKept(page, worktree);
expect(existsSync(path.join(worktree.workspaceDirectory, "NEW.md"))).toBe(true);
});
test("a failed git read shows its error and keeps the worktree", async ({ page }) => {
const worktree = await createWorktree("archive-risk-read-failure");
await seedRiskyWorktree(client, worktree.workspaceDirectory);
const relay = await relayDaemon(page, {
workspaceId: worktree.workspaceId,
failCheckoutStatusFor: worktree.workspaceDirectory,
});
await openSidebarRow(page, worktree.workspaceId);
await clickRowArchiveButton(page, worktree.workspaceId);
await expect(page.getByTestId("app-toast-message")).toHaveText("git status failed");
await expectWorktreeKept(page, worktree);
expect(relay.archiveRequests()).toBe(0);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
});
// Archives the open worktree with `startArchive`, opens the project's main workspace while the
// host's checkout status answer is held, then lets the answer through.
async function archiveWhileOpeningAnotherWorkspace(
page: Page,
slug: string,
startArchive: (worktree: { workspaceId: string }) => Promise<void>,
): Promise<string> {
const serverId = getServerId();
const worktree = await createWorktree(slug);
const statusRequest = await holdCheckoutStatusRequest(page, worktree.workspaceDirectory);
await openSidebarRow(page, worktree.workspaceId);
await workspaceRow(page, worktree.workspaceId).click();
await expectAppRoute(page, buildHostWorkspaceRoute(serverId, worktree.workspaceId));
statusRequest.arm();
await startArchive(worktree);
await statusRequest.waitForHeld();
await workspaceRow(page, worktree.project.workspaceId).click();
const openedRoute = buildHostWorkspaceRoute(serverId, worktree.project.workspaceId);
await expectAppRoute(page, openedRoute);
statusRequest.release();
await expect(undoButton(page)).toBeVisible();
// The redirect runs before the archive request, so once the host has archived the worktree
// any redirect has already happened.
await expect
.poll(() => hostListsWorkspace(worktree.workspaceId), { timeout: 30_000 })
.toBe(false);
return openedRoute;
}
test("opening another workspace while the host answers keeps it open after a row archive", async ({
page,
}) => {
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
page,
"archive-risk-switch-row",
(worktree) => clickRowArchiveButton(page, worktree.workspaceId),
);
await expectAppRoute(page, openedRoute);
});
test("opening another workspace while the host answers keeps it open after a git actions archive", async ({
page,
}) => {
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
page,
"archive-risk-switch-git",
() => gitActionsArchiveButton(page).click(),
);
await expectAppRoute(page, openedRoute);
});
test("archiving the open worktree from its git actions leaves it for a new workspace", async ({
page,
}) => {
const worktree = await createWorktree("archive-risk-git-redirect");
await openSidebarRow(page, worktree.workspaceId);
await workspaceRow(page, worktree.workspaceId).click();
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), worktree.workspaceId));
await gitActionsArchiveButton(page).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/new");
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
});
});
@@ -3,7 +3,7 @@ env APP_ID=sh.paseo.debug
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
# unselected PINNED workspace row. Ordinary workspace rows are not draggable.
# Hold past press feedback (130ms) and drag arming (180ms), then release.
# Hold past drag arming (180ms), then release.
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"sidebar-close\"" 10000
@@ -12,7 +12,7 @@ press "id=\"sidebar-close\""
wait "label=\"Open menu\"" 5000
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
longpress "id=\"${WORKSPACE_ROW}\"" 400
longpress "id=\"${WORKSPACE_ROW}\"" 250
press "id=\"${WORKSPACE_ROW}\""
# A released drag must not swallow the next tap: navigation closes the sidebar.
wait "label=\"Open menu\"" 5000
@@ -85,20 +85,6 @@ agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \
--artifacts-dir /tmp/paseo-model-tablet-qa
```
`model-tablet-profile.android.ad` taps the `+` of a model row in New workspace, asserts the seeded
profile form opens, and asserts that closing the form leaves the remembered model selected. Run it
on the same tablet setup, after `model-tablet.android.ad` or with `MODEL_LABEL` remembered, against a
host with no agent profiles: with a profile saved, the picker opens on its root view, which has no
Back. Set `PROFILE_MODEL_ID` to a different model on the provider's first page:
```sh
agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet-profile.android.ad \
--serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
--env PROVIDER_ID=codex --env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" \
--env PROFILE_MODEL_ID="$MODEL_QA_PROFILE_MODEL_ID"
```
The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open;
a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
@@ -1,22 +0,0 @@
# Connected host with MODEL_LABEL selected and no agent profiles, 1600x2560 at density 320. See README.md.
# FORM_URL=paseo://new?serverId=<serverId>; PROFILE_MODEL_ID is another catalog model with no profile.
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
env PROVIDER_ID=codex
open "${APP_ID}" "${FORM_URL}"
wait "label=\"Workspace project\"" 20000
press "id=\"combined-model-selector\""
wait "id=\"combobox-desktop-container\"" 10000
# A remembered model opens its provider's list. Return to the provider page first.
press "label=\"Back\""
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
press "id=\"model-provider-${PROVIDER_ID}\""
wait "id=\"compact-model-list\"" 3000
# The row's profile action sits inside Android's independent scroll viewport.
press "id=\"model-create-profile-${PROVIDER_ID}-${PROFILE_MODEL_ID}\""
wait "text=\"Add agent profile\"" 5000
# Closing the form keeps the remembered model: the tap did not also select the row.
back
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
close
@@ -1,83 +0,0 @@
// Record a sidebar toast through timeout on the 2560×1600 Android tablet.
// Pass a vertical sample line through the card's plain interior, away from text,
// icons, and Android's clipboard preview, plus the visible-to-dismissed interval:
// node .../toast-dismissal.mjs recording.mp4 sample-x start-seconds end-seconds
// This checks actual native frames; a browser layout test misses this regression.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const [, , recording, sampleX, start, end] = process.argv;
assert(recording && sampleX && start && end, "Pass recording, sample x, start and end seconds");
const { frames } = JSON.parse(
execFileSync(
"ffprobe",
[
"-v",
"error",
"-select_streams",
"v",
"-show_entries",
"frame=best_effort_timestamp_time",
"-of",
"json",
recording,
],
{ encoding: "utf8" },
),
);
const selectedFrames = frames
.map((frame, index) => ({ time: Number(frame.best_effort_timestamp_time), index }))
.filter(({ time }) => time >= Number(start) && time <= Number(end));
assert(selectedFrames.length > 2, "Capture at least three frames in the supplied interval");
const firstFrame = selectedFrames[0].index;
const endFrame = selectedFrames.at(-1).index + 1;
const bytesPerFrame = 800;
// Bound both output and buffer to the requested frames, even for a long recording.
const pixels = execFileSync(
"ffmpeg",
[
"-v",
"fatal",
"-i",
recording,
"-vf",
`trim=start_frame=${firstFrame}:end_frame=${endFrame},crop=2:400:${Number(sampleX)}:1100,format=gray`,
"-fps_mode",
"passthrough",
"-f",
"rawvideo",
"-",
],
{ maxBuffer: selectedFrames.length * bytesPerFrame + 1024 },
);
assert.equal(
pixels.length,
selectedFrames.length * bytesPerFrame,
"Every selected frame must be decoded",
);
const positions = [];
let disappeared = false;
for (const [index, { time }] of selectedFrames.entries()) {
const column = pixels.subarray(index * bytesPerFrame, (index + 1) * bytesPerFrame);
const runs = [];
let run = [];
for (let y = 0; y < 400; y++) {
if (column[y * 2] > 250) run.push(y);
else {
if (run.length > 40) runs.push(run);
run = [];
}
}
if (run.length > 40) runs.push(run);
runs.sort((a, b) => b.length - a.length);
if (runs.length) {
const card = runs[0];
positions.push({ time, centre: 1100 + (card[0] + card.at(-1)) / 2 });
} else if (positions.length) disappeared = true;
}
assert(positions.length > 2, "Capture at least three visible card frames");
assert(disappeared, "Recording must include the card disappearing");
const centres = positions.map(({ centre }) => centre);
const shift = Math.max(...centres) - Math.min(...centres);
console.log(JSON.stringify({ positions, maximumShiftPixels: shift }, null, 2));
assert(shift <= 2, `Toast moved ${shift}px during dismissal`);
@@ -1,27 +0,0 @@
# Android Usage drop regressions
Use a device you own and fixed reports from an isolated daemon. Keep recordings in `.artifacts/`. Do not upload recordings containing account details.
## Same-slot drop
Open Usage with two cards that fit without scrolling. Run:
```sh
ANDROID_SERIAL=emulator-5580 node packages/app/e2e/mobile/usage-drag/android.mjs
```
Set `PASEO_DEVICE_SESSION` to reuse your Agent Device session. The test holds the first handle, moves within its slot, drops it, then swipes the sheet body. It asserts sheet movement while the finger is still down: after release, the sheet can snap back to its starting position.
## Old-order frame
Record a drop with Android `screenrecord`. Include the original order, the held arrangement, the release, and at least five seconds after release. Take a reference screenshot before recording. Use fixed labels that stay visible throughout the drop.
Create a JSON map of label names to screenshot rectangles `[x, y, width, height]`, in physical pixels. Crop each label tightly, without changing text or the pointer. Install `opencv-python-headless` in a local virtual environment under `.artifacts/`, then run:
```sh
.artifacts/frames-venv/bin/python packages/app/e2e/mobile/usage-drag/frames.py \
--reference .artifacts/before.png --labels .artifacts/labels.json \
.artifacts/drop-up.mp4 .artifacts/drop-down.mp4
```
The check requires recognized labels in the first and last frames and a changed final order. After every label first reaches its final slot, it fails on any frame showing all original slots again. It prints the offending frame numbers for each recording. This exercises the Android compositor; a JavaScript render test cannot establish which intermediate commit reached the screen.
@@ -1,71 +0,0 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout } from "node:timers/promises";
// Open Usage with a fixture whose cards fit without scrolling before running.
// A same-slot drop must release the list's recognizer so a body swipe moves the sheet.
const serial = process.env.ANDROID_SERIAL;
assert.ok(serial, "Set ANDROID_SERIAL to the device owned by this test");
const session = process.env.PASEO_DEVICE_SESSION ?? "usage-drag-regression";
const artifacts = resolve(".artifacts/usage-drag-regression");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync(
"agent-device",
[...args, "--platform", "android", "--serial", serial, "--session", session],
{ encoding: "utf8", timeout: 30_000 },
);
}
function adb(...args) {
return execFileSync("adb", ["-s", serial, ...args], { timeout: 30_000 });
}
function snapshot() {
return JSON.parse(ad("snapshot", "--json")).data.nodes;
}
function touch(kind, x, y) {
adb("shell", "input", "motionevent", kind, String(Math.round(x)), String(Math.round(y)));
}
try {
ad("open", process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug");
const nodes = snapshot();
writeFileSync(resolve(artifacts, "before.json"), JSON.stringify(nodes));
const handle = nodes.find((node) => node.identifier === "usage-card-drag-handle");
const list = nodes.find((node) => node.identifier === "usage-list");
assert.ok(handle?.rect && list?.rect, "Open Usage with a visible card handle");
const x = handle.rect.x + handle.rect.width / 2;
const y = handle.rect.y + handle.rect.height / 2;
touch("DOWN", x, y);
await setTimeout(600);
touch("MOVE", x, y + 60);
await setTimeout(200);
touch("MOVE", x, y + 90);
touch("MOVE", x, y + 60);
ad("screenshot", resolve(artifacts, "dragging.png"));
touch("UP", x, y + 60);
await setTimeout(2000);
ad("screenshot", resolve(artifacts, "dropped.png"));
const swipeX = list.rect.x + list.rect.width / 2;
touch("DOWN", swipeX, y + 80);
for (let offset = 90; offset <= 500; offset += 10) {
touch("MOVE", swipeX, y + offset);
}
const during = snapshot();
writeFileSync(resolve(artifacts, "during.json"), JSON.stringify(during));
ad("screenshot", resolve(artifacts, "during.png"));
touch("UP", swipeX, y + 500);
await setTimeout(1500);
const after = snapshot();
writeFileSync(resolve(artifacts, "after.json"), JSON.stringify(after));
ad("screenshot", resolve(artifacts, "after.png"));
const beforeSheet = nodes.find((node) => node.label === "Bottom sheet handle");
const afterSheet = during.find((node) => node.label === "Bottom sheet handle");
assert.ok(beforeSheet?.rect, "Usage must start as a sheet");
assert.ok(
!afterSheet || afterSheet.rect.y > beforeSheet.rect.y + 50,
"A body swipe after a same-slot drop must move the sheet",
);
} finally {
ad("close");
}
@@ -1,59 +0,0 @@
"""Check native drop recordings against visible row labels (requires opencv-python-headless).
Supply a before screenshot and a JSON map of label names to [x, y, width, height].
A recording must start in the original order and end in a different order. Once
all labels reach their final slots, any frame returning to the original slots fails.
Keep the pointer clear of the labels and use fixed fixture data.
"""
import argparse
import json
from pathlib import Path
import cv2
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--reference", required=True)
parser.add_argument("--labels", required=True)
parser.add_argument("--top", type=int, default=0)
parser.add_argument("--bottom", type=int)
parser.add_argument("videos", nargs="+")
args = parser.parse_args()
reference = cv2.imread(args.reference, cv2.IMREAD_GRAYSCALE)
assert reference is not None, "Cannot read reference image"
labels = json.loads(Path(args.labels).read_text())
results = []
for video in args.videos:
capture = cv2.VideoCapture(video)
frames = []
while True:
ok, image = capture.read()
if not ok:
break
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
positions = {}
for label, (x, y, width, height) in labels.items():
template = reference[y:y + height, x:x + width]
matches = cv2.matchTemplate(gray[args.top:args.bottom, x:x + width], template, cv2.TM_CCOEFF_NORMED)
_, confidence, _, point = cv2.minMaxLoc(matches)
positions[label] = (point[1] + args.top, confidence)
frames.append(positions)
capture.release()
assert frames, f"No frames in {video}"
initial, final = frames[0], frames[-1]
assert all(confidence >= 0.95 for _, confidence in initial.values()), f"Unrecognized initial labels: {video}"
assert all(confidence >= 0.95 for _, confidence in final.values()), f"Unrecognized final labels: {video}"
assert sorted(initial, key=lambda key: initial[key][0]) != sorted(final, key=lambda key: final[key][0]), f"Drop did not reorder: {video}"
def matches_slots(frame, target):
return all(confidence >= 0.95 and abs(y - target[label][0]) <= 2
for label, (y, confidence) in frame.items())
reached_final = False
wrong = []
for number, frame in enumerate(frames, start=1):
reached_final = reached_final or matches_slots(frame, final)
if reached_final and matches_slots(frame, initial):
wrong.append(number)
results.append({"recording": video, "oldOrderFrames": wrong, "count": len(wrong)})
print(json.dumps(results, indent=2))
raise SystemExit(1 if any(result["count"] for result in results) else 0)
+2 -50
View File
@@ -1,8 +1,6 @@
import { mkdtemp, writeFile, rm, rename } from "node:fs/promises";
import { tmpdir } from "node:os";
import { writeFile, rm, rename } from "node:fs/promises";
import path from "node:path";
import type { Page } from "@playwright/test";
import { copyPluginExample, pluginRequirements } from "./helpers/plugin-fixture";
import { copyPluginExample } from "./helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
import { test as base } from "./fixtures";
import {
@@ -16,7 +14,6 @@ import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{
startup: { release(): Promise<void>; fail(): Promise<void> };
promptRejection: void;
composerMetadata: { set(patch: Record<string, string>): Promise<void> };
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
@@ -101,39 +98,6 @@ export default function contribute(server) {
await client.close();
}
},
composerMetadata: async ({ page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-metadata-"));
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "composer-metadata", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`import { useComposer } from "@getpaseo/plugin/client";
function Holder() {
globalThis.metadataComposer = useComposer();
return null;
}
export default function contribute(client) {
client.addComposerItem({ id: "holder", Component: Holder });
return () => {};
}`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await provide({ set: (patch) => setComposerMetadata(page, patch) });
} finally {
await client.removePlugin("composer-metadata");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await rm(directory, { recursive: true, force: true });
await client.close();
}
},
creation: async ({ page }, provide) => {
const creation = await createCreationScenario(page);
try {
@@ -162,15 +126,3 @@ export default function contribute(client) {
}
},
});
type MetadataComposerGlobal = typeof globalThis & {
metadataComposer?: { setMetadata(patch: Record<string, string>): Promise<void> };
};
async function setComposerMetadata(page: Page, patch: Record<string, string>) {
await page.waitForFunction(() => (globalThis as MetadataComposerGlobal).metadataComposer);
await page.evaluate(
(value) => (globalThis as MetadataComposerGlobal).metadataComposer!.setMetadata(value),
patch,
);
}
@@ -505,14 +505,10 @@ export async function expectCreateProfileFromModelRow(
page: Page,
input: { provider: string; modelId: string; modelLabel: string },
): Promise<void> {
const action = pickerViewport(page).getByTestId(
`model-create-profile-${input.provider}-${input.modelId}`,
);
// Until its row is hovered the plus is invisible, so a tap on its spot reaches the row.
await expect(action).toHaveCSS("pointer-events", "none");
await modelRow(page, input).hover();
await expect(action).toHaveCSS("pointer-events", "auto");
await action.click();
await pickerViewport(page)
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
.click();
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
@@ -21,6 +21,7 @@ export interface OlderTimelinePagesGate {
getRepeatedEntryCount(): number;
getOwnedEntryCountContaining(text: string): number;
releasePage(pageNumber: number): void;
waitForRequestCount(count: number): Promise<void>;
}
export interface DaemonHydrationGate {
@@ -370,6 +371,15 @@ export async function holdAgentOlderTimelinePages(
const ownedEntries = new Map<string, string>();
const releasedPages = new Set<number>();
const delayedForwards = new Map<number, Array<() => void>>();
const requestWaiters = new Map<number, Array<() => void>>();
const resolveRequestWaiters = () => {
for (const [count, resolvers] of requestWaiters) {
if (requestCount < count) continue;
requestWaiters.delete(count);
for (const resolve of resolvers) resolve();
}
};
const routePattern =
daemonPort === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(String(daemonPort));
@@ -383,6 +393,7 @@ export async function holdAgentOlderTimelinePages(
sessionMessage.direction === "before"
) {
requestCount += 1;
resolveRequestWaiters();
}
server.send(message);
});
@@ -428,6 +439,14 @@ export async function holdAgentOlderTimelinePages(
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
delayedForwards.delete(pageNumber);
},
waitForRequestCount(count) {
if (requestCount >= count) return Promise.resolve();
return new Promise<void>((resolve) => {
const resolvers = requestWaiters.get(count) ?? [];
resolvers.push(resolve);
requestWaiters.set(count, resolvers);
});
},
};
}
@@ -26,7 +26,7 @@ const SURFACE_SELECTORS = {
input: '[data-testid="command-center-input"]',
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
results: '[data-testid="command-center-results"]',
tabs: '[data-testid="command-center-tabs"]',
scope: '[data-testid="command-center-files-scope"]',
fileRow: '[data-testid^="command-center-file-row-"]',
fileIcon: '[data-testid="command-center-file-icon"]',
fileLine: '[data-testid="command-center-file-line"]',
@@ -11,7 +11,7 @@ export async function seedSearchableCommandCenter() {
});
try {
const agents = [];
for (let index = 0; index < 3; index += 1) {
for (let index = 0; index < 60; index += 1) {
agents.push(
await createMockIdleAgent(seed.client, {
cwd: seed.repoPath,
@@ -49,27 +49,11 @@ export async function searchCommandCenter(panel: Locator, query: string) {
}
export async function warmCommandCenterRows(page: Page) {
// FlatList fills its overscan window in timed batches after the first visible frame,
// and a slow runner can still be mounting rows long after it. Start counting only
// once a quiet period passes with no row render.
await expect
.poll(() => page.evaluate(countRowRendersOverQuietPeriod), {
intervals: [0],
timeout: 15_000,
})
.toBe(0);
}
// Runs in the page: resets the render profile and counts row renders over two frames and 300ms.
async function countRowRendersOverQuietPeriod() {
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
await new Promise((resolve) => setTimeout(resolve, 300));
return Object.keys(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter((id) =>
id.startsWith("CommandCenterResultRow:"),
).length;
// FlatList fills its overscan window in timed batches after the first visible frame.
await page.waitForTimeout(600);
await page.evaluate(() => {
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
});
}
export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) {
@@ -89,17 +73,16 @@ export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: s
expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } });
}
const WORKSPACE_ROWS =
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"]):not([data-testid="command-center-workspace-agent"])';
/** The list mounts a window of rows, not every match. */
export async function expectCommandCenterWorkspaceWindow(panel: Locator, total: number) {
const count = await panel.locator(WORKSPACE_ROWS).count();
export async function expectCommandCenterAgentWindow(panel: Locator, total: number) {
const rows = panel.locator(
'[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])',
);
const count = await rows.count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThan(total);
}
export async function scrollToOldestCommandCenterWorkspace(panel: Locator) {
export async function scrollToOldestCommandCenterAgent(panel: Locator) {
await panel.getByTestId("command-center-results").evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
@@ -22,9 +22,14 @@ export async function observeCommandCenterScroll(page: Page): Promise<void> {
const discoverPanel = new MutationObserver(() => {
const panel = document.querySelector<HTMLElement>('[data-testid="command-center-panel"]');
const viewport = panel?.querySelector<HTMLElement>('[data-testid="command-center-results"]');
const firstResult = viewport?.querySelector<HTMLElement>('[role="button"]');
if (!viewport || !firstResult) return;
const firstResult = panel?.querySelector<HTMLElement>('[role="button"]');
if (!panel || !firstResult) return;
let viewport = firstResult.parentElement;
while (viewport && viewport !== panel && viewport.scrollHeight <= viewport.clientHeight) {
viewport = viewport.parentElement;
}
if (!viewport || viewport === panel) return;
discoverPanel.disconnect();
let frame = 0;
@@ -114,8 +119,15 @@ export async function expectVisibleKeyboardNavigationNotToScroll(
for (let index = 0; index < 4; index += 1) await page.keyboard.press("ArrowDown");
const scrollSamples = await panel
.getByTestId("command-center-results")
.evaluate(async (viewport) => {
.getByRole("button")
.first()
.evaluate(async (firstResult) => {
let viewport = firstResult.parentElement;
while (viewport && viewport.scrollHeight <= viewport.clientHeight) {
viewport = viewport.parentElement;
}
if (!viewport) throw new Error("Could not find the command center scroll viewport");
const samples: number[] = [];
for (let frame = 0; frame < 20; frame += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
@@ -127,8 +139,7 @@ export async function expectVisibleKeyboardNavigationNotToScroll(
expect(Math.max(...scrollSamples)).toBe(0);
}
/** All leads with three recent workspaces; a query searches every workspace. */
export async function expectRecentWorkspacesUntilSearch(panel: Locator): Promise<void> {
export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locator): Promise<void> {
const workspaces = panel.locator(
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"])',
);
@@ -139,11 +150,9 @@ export async function expectRecentWorkspacesUntilSearch(panel: Locator): Promise
),
).size;
await expect(panel.getByText("Recent workspaces", { exact: true })).toBeVisible();
await expect.poll(distinctWorkspaceCount).toBe(3);
await expect.poll(distinctWorkspaceCount).toBe(5);
await panel.getByTestId("command-center-input").fill("Command Center Scroll");
await expect(panel.getByText("Recent workspaces", { exact: true })).toHaveCount(0);
await expect.poll(distinctWorkspaceCount).toBe(9);
}
@@ -1,20 +1,5 @@
import { expect, type Locator, type Page } from "@playwright/test";
export type CommandCenterTabName = "All" | "Workspaces" | "Files" | "Actions";
const TAB_PLACEHOLDERS: Record<CommandCenterTabName, string> = {
All: "Search workspaces, files, and actions...",
Workspaces: "Search workspaces...",
Files: "Search files...",
Actions: "Search actions...",
};
/** Outside a workspace there are no files to search, so All does not offer them. */
const TAB_PLACEHOLDERS_WITHOUT_WORKSPACE: Record<CommandCenterTabName, string> = {
...TAB_PLACEHOLDERS,
All: "Search workspaces and actions...",
};
// Opens the command center / global search palette from the sidebar and returns its panel.
export async function openCommandCenter(page: Page): Promise<Locator> {
await page.getByTestId("sidebar-search").click();
@@ -27,46 +12,3 @@ export async function closeCommandCenter(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).not.toBeVisible();
}
function commandCenterTab(panel: Locator, name: CommandCenterTabName): Locator {
return panel.getByTestId("command-center-tabs").getByRole("button", { name, exact: true });
}
export async function selectCommandCenterTab(
panel: Locator,
name: CommandCenterTabName,
options: { workspaceOpen?: boolean } = {},
): Promise<void> {
await commandCenterTab(panel, name).click();
await expectCommandCenterTab(panel, name, options);
}
/**
* The tab is selected and the search field asks for what that tab lists. Pass
* `{ workspaceOpen: false }` when the command center was opened outside a workspace.
*/
export async function expectCommandCenterTab(
panel: Locator,
name: CommandCenterTabName,
options: { workspaceOpen?: boolean } = {},
): Promise<void> {
const placeholders =
options.workspaceOpen === false ? TAB_PLACEHOLDERS_WITHOUT_WORKSPACE : TAB_PLACEHOLDERS;
await expect(commandCenterTab(panel, name)).toHaveAttribute("aria-selected", "true");
await expect(panel.getByTestId("command-center-input")).toHaveAttribute(
"placeholder",
placeholders[name],
);
}
/** Exactly these tabs are offered, in this order. */
export async function expectCommandCenterTabs(
panel: Locator,
names: readonly CommandCenterTabName[],
): Promise<void> {
await expect(panel.getByTestId("command-center-tabs").getByRole("button")).toHaveText([...names]);
}
export function commandCenterSection(panel: Locator, title: string): Locator {
return panel.getByTestId("command-center-results").getByText(title, { exact: true });
}
@@ -1,55 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** The tones a composer toolbar control paints: its glyph's stroke and its label's text color. */
export interface ComposerControlTone {
/** The visible glyph's stroke, or null when the control paints no glyph. */
glyph: string | null;
/** The visible label's color, or null when the control is icon-only. */
label: string | null;
}
export function composerControl(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true }).first();
}
/**
* Reads the colors the control actually paints. The toolbar glyph and its label are separate
* nodes, so this is the only way to tell whether they match.
*/
export async function readComposerControlTone(control: Locator): Promise<ComposerControlTone> {
return control.evaluate((element) => {
const visible = (node: Element) => node.getClientRects().length > 0;
const glyph = Array.from(element.querySelectorAll("svg")).find(visible);
const label = Array.from(element.querySelectorAll("*"))
.filter(visible)
.find((node) =>
[...node.childNodes].some(
(child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim(),
),
);
return {
glyph: glyph ? getComputedStyle(glyph).stroke : null,
label: label ? getComputedStyle(label).color : null,
};
});
}
export function expectGlyphMatchesLabel(tone: ComposerControlTone, name: string): void {
expect(tone.label, `${name} paints a label`).not.toBeNull();
expect(tone.glyph, `${name}'s glyph matches its label`).toBe(tone.label);
}
export function expectIconOnlyGlyph(tone: ComposerControlTone, name: string): void {
expect(tone.label, `${name} is icon-only`).toBeNull();
expect(tone.glyph, `${name} paints a glyph`).not.toBeNull();
}
/** The color the browser paints in a text input's placeholder. */
export async function readComposerPlaceholderTone(input: Locator): Promise<string | null> {
return input.evaluate((element) => getComputedStyle(element, "::placeholder").color);
}
/** The color the browser paints for a text node. */
export async function readComposerTextTone(text: Locator): Promise<string | null> {
return text.evaluate((element) => getComputedStyle(element).color);
}
@@ -41,6 +41,14 @@ export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Pr
return card;
}
/** Taps the meter on a touch screen and returns its details once they are on screen. */
export async function tapContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
await page.getByRole("img", { name, exact: true }).tap({ timeout: 30_000 });
const card = contextWindowDetails(page);
await expect(card).toBeVisible();
return card;
}
/** Walks the pointer from the meter up onto its hover card, across the gap between them. */
export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise<void> {
const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox();
+1 -16
View File
@@ -142,8 +142,6 @@ export async function createCreationScenario(page: Page) {
const requests = await observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
let workspaceId = project.workspaceId;
// Workspaces the scenario opens itself to evict the deck; they are not creations under test.
const evictionWorkspaceIds = new Set<string>();
return {
cleanup: project.cleanup,
async openWorkspaceForm(isolation: "local" | "worktree") {
@@ -234,9 +232,7 @@ export async function createCreationScenario(page: Page) {
await requests.settled();
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId &&
workspace.id !== project.workspaceId &&
!evictionWorkspaceIds.has(workspace.id),
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
);
expect(workspaces).toHaveLength(1);
workspaceId = workspaces[0]!.id;
@@ -258,16 +254,6 @@ export async function createCreationScenario(page: Page) {
.filter(({ agent }) => agent.workspaceId === workspaceId)
.map(({ agent }) => agent.id);
},
async expectAgentMetadata(metadata: Record<string, unknown>) {
await expect
.poll(
async () =>
(await project.client.fetchAgents()).entries.find(
({ agent }) => agent.workspaceId === workspaceId,
)?.agent.metadata,
)
.toEqual(metadata);
},
async expectAgentTitle(title: string) {
await expect
.poll(
@@ -290,7 +276,6 @@ export async function createCreationScenario(page: Page) {
source: { kind: "directory", path: project.repoPath },
});
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
evictionWorkspaceIds.add(result.workspace.id);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
@@ -33,10 +33,8 @@ async function createRunningMockAgent(
return { id: agent.id, title };
}
// Agents are found through their workspace: the query names the agent, the row shows it.
async function searchCommandCenter(page: Page, query: string): Promise<void> {
async function openCommandCenter(page: Page): Promise<void> {
await page.getByTestId("sidebar-search").click();
await page.getByTestId("command-center-input").fill(query);
}
export class DirectoryBootstrapScenario {
@@ -80,10 +78,8 @@ export class DirectoryBootstrapScenario {
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
await expect(workspaceLink).toHaveCount(1);
await expect(workspaceLink).toBeVisible();
await searchCommandCenter(this.page, agent.title);
const agentLink = this.page
.getByTestId("command-center-workspace-agent")
.getByText(agent.title, { exact: true });
await openCommandCenter(this.page);
const agentLink = this.page.getByText(agent.title, { exact: true });
await expect(agentLink).toHaveCount(1);
await expect(agentLink).toBeVisible();
await this.page.keyboard.press("Escape");
@@ -115,10 +111,8 @@ export class DirectoryBootstrapScenario {
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
await expect(workspaceLink).toHaveCount(1);
await expect(workspaceLink).toBeVisible();
await searchCommandCenter(this.page, agent.title);
const agentLink = this.page
.getByTestId("command-center-workspace-agent")
.getByText(agent.title, { exact: true });
await openCommandCenter(this.page);
const agentLink = this.page.getByText(agent.title, { exact: true });
await expect(agentLink).toHaveCount(1);
await expect(agentLink).toBeVisible();
await agentLink.click();
@@ -46,12 +46,3 @@ export async function downloadFileFromExplorer(page: Page, fileName: string): Pr
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
return downloaded;
}
export async function downloadFileFromPreview(page: Page): Promise<Download> {
await expect(page.getByText("Binary preview unavailable", { exact: true })).toBeVisible();
const button = page.getByRole("button", { name: "Download", exact: true });
await expect(button).toBeVisible();
const downloaded = page.waitForEvent("download");
await button.click();
return downloaded;
}
@@ -232,11 +232,8 @@ export async function expectNewWorkspaceControlsEnabled(page: Page): Promise<voi
});
}
export async function openNewWorkspaceProjectPickerWithShortcut(
page: Page,
shortcut = "Control+P",
): Promise<void> {
await page.keyboard.press(shortcut);
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
await page.keyboard.press("Control+P");
const searchInput = page.getByPlaceholder("Search projects");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
@@ -92,7 +92,7 @@ export async function measureRelayRestartDuringStream(input: {
agentTitle: string;
}): Promise<RelayDeploymentMeasurements> {
const { page, relay, agentTitle } = input;
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting" });
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
const beforeOutage = await latestAssistantText(page);
await waitForAssistantTextToGrow(page, beforeOutage);
@@ -131,7 +131,6 @@ export interface SeedDaemonClient {
currentModeId: string | null;
status: string;
title?: string | null;
metadata?: Record<string, unknown>;
};
}>;
}>;
@@ -106,33 +106,6 @@ export async function changeSettingsInterfaceFontSize(page: Page, size: number):
await input.fill(String(size));
await input.press("Tab");
await expect(input).toHaveValue(String(size));
await expect(
page.getByRole("navigation", { name: "Settings" }).getByText("Appearance", { exact: true }),
).toHaveCSS("font-size", `${size}px`);
}
export async function changeSettingsInterfaceFontSizeKeepingSidebarPosition(
page: Page,
size: number,
): Promise<number> {
const navigation = page.getByRole("navigation", { name: "Settings" });
const anchor = navigation.getByRole("button", { name: "About", exact: true });
const before = await anchor.evaluate((node) => ({
top: node.getBoundingClientRect().top,
height: node.getBoundingClientRect().height,
}));
await changeSettingsInterfaceFontSize(page, size);
// Larger fonts reflow rows. Preserve the visible destination, rather than its old pixel offset.
await expect
.poll(() =>
anchor.evaluate((node, top) => Math.abs(node.getBoundingClientRect().top - top), before.top),
)
.toBeLessThan(before.height);
const offset = await page
.getByTestId("settings-sidebar-scroll-body")
.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
@@ -640,192 +613,3 @@ export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
export async function revealSettingsSyntaxTheme(page: Page) {
await page.getByLabel(/Highlight theme:/).scrollIntoViewIfNeeded();
const offset = await page
.getByTestId("settings-detail-scroll-body")
.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
const keywordColor = await page
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
.getByText("export", { exact: true })
.first()
.evaluate((node) => getComputedStyle(node).color);
return { offset, keywordColor };
}
export async function changeSettingsSyntaxTheme(page: Page, theme: string): Promise<void> {
await page.getByLabel(/Highlight theme:/).click();
await page.getByRole("menuitem", { name: theme, exact: true }).click();
await expect(page.getByLabel(`Highlight theme: ${theme}`, { exact: true })).toBeVisible();
}
export async function expectSettingsSyntaxColorChanged(
page: Page,
previousColor: string,
): Promise<void> {
await expect(
page
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
.getByText("export", { exact: true })
.first(),
).not.toHaveCSS("color", previousColor);
}
export async function changeSettingsTypography(page: Page): Promise<void> {
const input = page.getByRole("textbox", { name: "Interface font size", exact: true });
await input.fill("18");
await input.press("Enter");
await expect(input).toHaveValue("18");
await expect(input).toHaveCSS("font-size", "18px");
await expect(page.getByText("Code size", { exact: true })).toHaveCSS("font-size", "18px");
const navigation = page.getByRole("navigation", { name: "Settings" });
await expect(navigation.getByText("Appearance", { exact: true })).toHaveCSS("font-size", "18px");
await expect(navigation.getByText("Providers", { exact: true })).toHaveCSS("font-size", "18px");
await expect(page.getByText("Dracula", { exact: true })).toHaveCSS("font-size", "18px");
const familyInput = page.getByRole("textbox", { name: "Code font family", exact: true });
await familyInput.fill("Courier New, Courier, monospace");
await familyInput.press("Enter");
const codeInput = page.getByRole("textbox", { name: "Code font size", exact: true });
await codeInput.fill("16");
await codeInput.press("Enter");
await expect(codeInput).toHaveValue("16");
const preview = page.getByRole("img", {
name: "Live preview of content typography, syntax theme, and code font",
});
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS("font-size", "16px");
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
"line-height",
"24px",
);
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
"font-family",
/Courier/,
);
const contentInput = page.getByRole("textbox", { name: "Content font size", exact: true });
await contentInput.fill("20");
await contentInput.press("Enter");
await expect(preview.getByText("Readable content and code preview", { exact: true })).toHaveCSS(
"font-size",
"20px",
);
}
export async function normalizeSettingsFontSizes(page: Page): Promise<void> {
for (const [name, minimum, maximum] of [
["Interface font size", "10", "21"],
["Content font size", "10", "21"],
["Code font size", "9", "22"],
] as const) {
const input = page.getByRole("textbox", { name, exact: true });
await input.fill("999");
await input.press("Enter");
await expect(input).toHaveValue(maximum);
// Clamp again when the saved value is already at the maximum.
await input.fill("999");
await input.press("Tab");
await expect(input).toHaveValue(maximum);
await input.fill("");
await input.press("Enter");
await expect(input).toHaveValue(maximum);
await input.fill("1");
await input.press("Enter");
await expect(input).toHaveValue(minimum);
await input.fill("1");
await input.press("Tab");
await expect(input).toHaveValue(minimum);
}
}
/** Sample actual paints, including the first workspace frame after Settings. */
export async function captureCompactSidebarReturn(page: Page, returnAction: () => Promise<void>) {
await page.evaluate(() => {
const capture = {
frames: [] as {
time: number;
pathname: string;
sidebarVisible: boolean;
settingsVisible: boolean;
closeX: number | null;
}[],
stopped: false,
};
Object.assign(window, { __sidebarFrameCapture: capture });
const start = performance.now();
function sample() {
if (capture.stopped) return;
const close = document.querySelector('[data-testid="sidebar-close"]');
capture.frames.push({
time: performance.now() - start,
pathname: location.pathname,
settingsVisible:
document
.querySelector('[data-testid="settings-sidebar"]')
?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ?? false,
sidebarVisible:
(close?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ?? false) &&
close!.getBoundingClientRect().x >= 0 &&
close!.getBoundingClientRect().right <= innerWidth,
closeX: close?.getBoundingClientRect().x ?? null,
});
requestAnimationFrame(sample);
}
requestAnimationFrame(sample);
});
await returnAction();
return page.evaluate(async () => {
const capture = (
window as typeof window & {
__sidebarFrameCapture: {
frames: {
time: number;
pathname: string;
sidebarVisible: boolean;
settingsVisible: boolean;
closeX: number | null;
}[];
stopped: boolean;
};
}
).__sidebarFrameCapture;
await new Promise<void>((resolve) => setTimeout(resolve, 1500));
capture.stopped = true;
return capture.frames;
});
}
export async function captureCompactSidebarContext(page: Page) {
const sidebar = await page.getByTestId("sidebar-project-workspace-list-scroll").elementHandle();
if (!sidebar) throw new Error("Open sidebar has no workspace list");
await expect
.poll(() => sidebar.evaluate((el) => el.scrollHeight - el.clientHeight))
.toBeGreaterThan(0);
const offset = await sidebar.evaluate((el) => {
el.scrollTop = el.scrollHeight;
return el.scrollTop;
});
expect(offset).toBeGreaterThan(0);
return async () => {
expect(await sidebar.evaluate((el) => el.isConnected)).toBe(true);
expect(await sidebar.evaluate((el) => el.scrollTop)).toBe(offset);
await sidebar.dispose();
};
}
export async function expectCompactSidebarOpen(page: Page) {
await expect(page.getByRole("button", { name: "Close sidebar", exact: true })).toBeInViewport();
}
export async function closeCompactSidebar(page: Page) {
await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
await expect(
page.getByRole("button", { name: "Open menu", exact: true }).first(),
).toBeInViewport();
}
export async function openSettingsFromCompactSidebar(page: Page) {
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expectAppRoute(page, buildSettingsRoute());
await expectCompactSettingsList(page);
}
@@ -132,6 +132,31 @@ export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void>
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
}
/**
* Records whether a workspace hover card mounts at any point from now on, including one that
* closes again before an assertion could see it.
*/
export async function watchWorkspaceHoverCard(
page: Page,
): Promise<{ hasOpened: () => Promise<boolean> }> {
await page.evaluate(() => {
const target = window as unknown as { __workspaceHoverCardOpened?: boolean };
const isOpen = () => document.querySelector('[data-testid="workspace-hover-card"]') !== null;
target.__workspaceHoverCardOpened = isOpen();
new MutationObserver(() => {
if (isOpen()) target.__workspaceHoverCardOpened = true;
}).observe(document.body, { childList: true, subtree: true });
});
return {
hasOpened: () =>
page.evaluate(
() =>
(window as unknown as { __workspaceHoverCardOpened?: boolean })
.__workspaceHoverCardOpened === true,
),
};
}
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
const workspaceKey = `${getServerId()}:${workspaceId}`;
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
@@ -427,11 +427,7 @@ export async function holdOlderHistoryPages(
const loader = page.getByTestId("load-older-history-spinner");
return {
async expectRequestedPages(count) {
await expect
.poll(() => gate.getRequestCount(), {
message: `older history requests ${count} page(s)`,
})
.toBeGreaterThanOrEqual(count);
await gate.waitForRequestCount(count);
expect(gate.getRequestCount()).toBe(count);
},
async expectSettledWithRequestedPages(count) {
@@ -147,46 +147,6 @@ export function usageModal(page: Page): Locator {
return visible(page, "usage-modal-body");
}
/** The Usage modal's cards, top to bottom, by report id. */
export async function expectUsageCardOrder(page: Page, reportIds: string[]): Promise<void> {
const cards = usageModal(page).locator('[data-testid^="usage-report-"]');
await expect(cards).toHaveCount(reportIds.length);
await expect
.poll(() =>
cards.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("data-testid")?.slice("usage-report-".length)),
),
)
.toEqual(reportIds);
}
/** Turns a card on or off from its header switch, named by its source and account. */
export async function setUsageCardOn(page: Page, name: string, on: boolean): Promise<void> {
const cardSwitch = usageModal(page).getByRole("switch", { name: `Show ${name}`, exact: true });
await cardSwitch.click();
await expect(cardSwitch).toBeChecked({ checked: on });
}
/** Drags a card by its handle until it sits above another card, with the mouse. */
export async function dragUsageCardAbove(
page: Page,
reportId: string,
targetReportId: string,
): Promise<void> {
const card = usageModal(page).getByTestId(`usage-report-${reportId}`);
const target = usageModal(page).getByTestId(`usage-report-${targetReportId}`);
await card.hover();
const handle = await card.getByTestId("usage-card-drag-handle").boundingBox();
const targetBox = await target.boundingBox();
if (!handle || !targetBox) throw new Error("Both cards must be on screen to drag.");
const x = handle.x + handle.width / 2;
await page.mouse.move(x, handle.y + handle.height / 2);
await page.mouse.down();
await page.mouse.move(x, handle.y + handle.height / 2 - 10, { steps: 3 });
await page.mouse.move(x, targetBox.y + 4, { steps: 12 });
await page.mouse.up();
}
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
export async function expectPinnedUsage(page: Page, windows: string[]): Promise<void> {
const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window");
@@ -207,18 +167,14 @@ export function pinRow(scope: Locator, source: string, window: string): Locator
/**
* A report card whose window rows do not pin: no pin toggle, no pin glyph, and nothing focusable
* but the header's buttons and on/off switch, so no row presses or highlights on hover.
* but buttons, so no row presses or highlights on hover.
*/
export async function expectUnpinnableRows(card: Locator): Promise<void> {
await expect(card.getByRole("checkbox")).toHaveCount(0);
await expect(card.locator('[data-testid^="usage-pin-"]')).toHaveCount(0);
const focusable = await card
.locator("[tabindex]")
.evaluateAll(
(nodes) =>
nodes.filter((node) => !["button", "switch"].includes(node.getAttribute("role") ?? ""))
.length,
);
.evaluateAll((nodes) => nodes.filter((node) => node.getAttribute("role") !== "button").length);
expect(focusable).toBe(0);
}
@@ -244,18 +200,6 @@ export async function openUsageFromItem(page: Page): Promise<void> {
await expect(usageModal(page)).toBeVisible();
}
export async function openUsageFromItemWithPressFeedback(page: Page): Promise<void> {
const summary = page.getByRole("button", { name: /^Usage:/ });
await summary.hover();
const hoveredFill = await summary.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await page.mouse.down();
await expect(summary).not.toHaveCSS("background-color", hoveredFill);
await page.mouse.up();
await expect(usageModal(page)).toBeVisible();
}
/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */
export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
const body = await usageModal(page).boundingBox();
@@ -317,19 +261,6 @@ export async function openUsageOptions(page: Page): Promise<void> {
await expect(visible(page, "usage-display-used")).toBeVisible();
}
/** Picks the host Usage shows, from the Host row of its Settings. */
export async function pickUsageHost(page: Page, serverId: string): Promise<void> {
await openUsageOptions(page);
await visible(page, "usage-options-host").click();
await visible(page, `usage-options-host-${serverId}`).click();
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
}
/** The host Usage shows, under its title; absent with a single host. */
export function usageModalHost(page: Page): Locator {
return visible(page, "usage-modal-host");
}
export function usageOptionsSurface(page: Page): Locator {
return page
.locator(
@@ -162,38 +162,3 @@ export function createGatedTeardown(): GatedTeardown {
release: () => writeFileSync(releasePath, "release\n"),
};
}
export function archivedToast(page: Page) {
return page.getByTestId("workspace-archived-toast");
}
export interface SidebarListLayout {
/** Bottom edge of the list's visible area. */
listBottom: number;
/** Bottom edges of the visible project and workspace rows, top to bottom. */
rowBottoms: number[];
rowHeight: number;
}
/** Scrolls the sidebar's workspace list to its end and reads where its rows sit. */
export async function scrollSidebarListToEnd(page: Page): Promise<SidebarListLayout> {
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
await list.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
return list.evaluate((element) => {
const visible = element.getBoundingClientRect();
const rows = Array.from(
element.querySelectorAll<HTMLElement>(
'[data-testid^="sidebar-workspace-row-"],[data-testid^="sidebar-project-row-"]',
),
)
.map((row) => row.getBoundingClientRect())
.filter((row) => row.height > 0 && row.bottom > visible.top && row.top < visible.bottom);
return {
listBottom: visible.bottom,
rowBottoms: rows.map((row) => row.bottom).sort((a, b) => a - b),
rowHeight: Math.min(...rows.map((row) => row.height)),
};
});
}
@@ -109,18 +109,18 @@ export async function expectReconnectingToastVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting" });
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
await expect(toast).toBeVisible({
timeout: options?.timeout ?? 30_000,
});
await expect(toast).toHaveText("Reconnecting");
await expect(toast).toHaveText("Reconnecting to host");
}
export async function expectReconnectingToastGone(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(page.getByRole("alert").filter({ hasText: "Reconnecting" })).toHaveCount(0, {
await expect(page.getByRole("alert").filter({ hasText: "Reconnecting to host" })).toHaveCount(0, {
timeout: options?.timeout ?? 30_000,
});
}
@@ -8,7 +8,6 @@ private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
private weak var activeModule: PaseoHardwareKeyboardModule?
private var isHardwareSubmitEnabled = false
private var overlayKeys: Set<String> = []
private var claimedShortcuts: [ClaimedShortcut] = []
/// 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
@@ -20,42 +19,6 @@ private let namedKeyInputs: [String: String] = [
"Enter": "\r",
]
/// A shortcut the window takes from the focused text control: Shift+Tab would
/// otherwise insert a tab, and Option+Shift+[ would type a quote.
private struct ClaimedShortcut {
let input: String
let modifierFlags: UIKeyModifierFlags
}
struct ClaimedShortcutRecord: Record {
@Field var code: String = ""
@Field var shift: Bool = false
@Field var alt: Bool = false
}
private let claimableKeyInputs: [String: String] = [
"Tab": "\t", "Space": " ", "Enter": "\r", "Escape": UIKeyCommand.inputEscape,
"ArrowUp": UIKeyCommand.inputUpArrow, "ArrowDown": UIKeyCommand.inputDownArrow,
"ArrowLeft": UIKeyCommand.inputLeftArrow, "ArrowRight": UIKeyCommand.inputRightArrow,
"Minus": "-", "Equal": "=", "BracketLeft": "[", "BracketRight": "]", "Backslash": "\\",
"Semicolon": ";", "Quote": "'", "Backquote": "`", "Comma": ",", "Period": ".", "Slash": "/",
]
/// UIKit matches a key command against the unmodified character, so
/// Option+Shift+[ is registered as "[" with both modifier flags.
private func claimedShortcut(_ record: ClaimedShortcutRecord) -> ClaimedShortcut? {
let code = record.code
let input: String?
if code.count == 4 && code.hasPrefix("Key") { input = code.suffix(1).lowercased() }
else if code.count == 6 && code.hasPrefix("Digit") { input = String(code.suffix(1)) }
else { input = claimableKeyInputs[code] }
guard let input else { return nil }
var modifierFlags: UIKeyModifierFlags = []
if record.shift { modifierFlags.insert(.shift) }
if record.alt { modifierFlags.insert(.alternate) }
return ClaimedShortcut(input: input, modifierFlags: modifierFlags)
}
/// Whether the first responder is mid-IME-composition.
///
/// A committing key has to stay out of the way while a CJK candidate is being
@@ -99,14 +62,6 @@ public class PaseoHardwareKeyboardModule: Module {
}
}
Function("setClaimedShortcuts") { (shortcuts: [ClaimedShortcutRecord]) in
let claimed = shortcuts.compactMap(claimedShortcut)
DispatchQueue.main.async {
claimedShortcuts = claimed
UIMenuSystem.main.setNeedsRebuild()
}
}
OnDestroy {
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
self.keyboardObservers = []
@@ -114,7 +69,6 @@ public class PaseoHardwareKeyboardModule: Module {
activeModule = nil
isHardwareSubmitEnabled = false
overlayKeys = []
claimedShortcuts = []
}
}
}
@@ -185,10 +139,7 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
{
commands.append(makeSubmitCommand())
}
if !isComposingText() {
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
}
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
return commands
}
@@ -221,18 +172,6 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
return command
}
/// The window's press stream still emits the shortcut; the command only keeps
/// the key away from the text control.
private func makeClaimedCommand(_ shortcut: ClaimedShortcut) -> UIKeyCommand {
let command = UIKeyCommand(
input: shortcut.input,
modifierFlags: shortcut.modifierFlags,
action: #selector(handleHardwareKeyboardShortcut(_:))
)
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
return command
}
@objc
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
guard canSubmitCurrentTextInput() else {
@@ -1,282 +0,0 @@
package sh.paseo.scroll
import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import android.text.InputType
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.widget.EditText
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.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
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.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Touches on chat text in the timeline's scroll view. Chat text is selectable, so a tap or a long
* press focuses it, and the focus may not scroll the timeline. The touches are injected into a real
* window, so touch mode, focus and the selection controller run as on device.
*/
@RunWith(AndroidJUnit4::class)
class SelectableTextTouchTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activity: Activity? = null
@After fun finish() {
activity?.finish()
}
// A reply whose top is on screen and whose bottom is below the viewport.
@Test fun tappingTextThatRunsBelowTheViewportKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.TOP)
timeline.tap(visibleY = EDGE_Y + 100)
assertTrue("The tap focuses the text", timeline.textHasFocus())
assertEquals("Offset after the tap", OFFSET, timeline.settledScrollY())
}
// A reply whose bottom is on screen and whose top is above the viewport, like a link at its end.
@Test fun tappingTextThatStartsAboveTheViewportKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.BOTTOM)
timeline.tap(visibleY = EDGE_Y - 100)
assertTrue("The tap focuses the text", timeline.textHasFocus())
assertEquals("Offset after the tap", OFFSET, timeline.settledScrollY())
}
// A reply that is still streaming gets new text while the user's tap holds its focus.
@Test fun aStreamingUpdateToTappedTextKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.BOTTOM)
timeline.tap(visibleY = EDGE_Y - 100)
assertTrue("The tap focuses the text", timeline.textHasFocus())
timeline.updateText("$CHAT_TEXT\nOne more line of the reply.")
assertEquals("Offset after the update", OFFSET, timeline.settledScrollY())
}
// The timeline changes height while tapped text holds focus, as when the composer above the
// keyboard loses its queued message.
@Test fun aTimelineResizeAfterATapKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.TOP)
timeline.tap(visibleY = EDGE_Y + 100)
assertTrue("The tap focuses the text", timeline.textHasFocus())
timeline.resize(height = VIEWPORT + 200)
assertEquals("Offset after the resize", OFFSET, timeline.settledScrollY())
}
@Test fun longPressSelectsAWordAndKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.TOP)
val word = timeline.wordAt(visibleY = EDGE_Y + 100)
timeline.longPress(visibleY = EDGE_Y + 100)
assertEquals("Selected text", word, timeline.selectedText())
assertEquals("Offset after the long press", OFFSET, timeline.settledScrollY())
}
// Inputs in the timeline, such as an agent's question form, still scroll into view to be typed in.
@Test fun tappingAnInputThatRunsBelowTheViewportRevealsIt() {
val timeline = openTimeline(edge = Edge.TOP, editable = true)
timeline.tap(visibleY = EDGE_Y + 100)
assertTrue("The tap focuses the input", timeline.textHasFocus())
assertNotEquals("Offset after the tap", OFFSET, timeline.settledScrollY())
}
private inner class Timeline(private val scroll: ReactScrollView, private val text: TextView) {
fun scrollTo(y: Int) {
instrumentation.runOnMainSync { scroll.scrollTo(0, y) }
awaitFrames(2)
}
fun tap(visibleY: Int) {
val downTime = SystemClock.uptimeMillis()
touch(downTime, MotionEvent.ACTION_DOWN, visibleY)
touch(downTime, MotionEvent.ACTION_UP, visibleY)
}
fun longPress(visibleY: Int) {
val downTime = SystemClock.uptimeMillis()
touch(downTime, MotionEvent.ACTION_DOWN, visibleY)
SystemClock.sleep(ViewConfiguration.getLongPressTimeout() * 2L)
touch(downTime, MotionEvent.ACTION_UP, visibleY)
}
fun resize(height: Int) {
instrumentation.runOnMainSync {
scroll.layoutParams = scroll.layoutParams.apply { this.height = height }
}
}
// Replaces the text and lays it out again at its top, the way a streaming reply mounts.
fun updateText(value: String) {
instrumentation.runOnMainSync {
text.setText(value)
text.measure(exact(WIDTH), unspecified())
text.layout(0, text.top, WIDTH, text.top + text.measuredHeight)
}
}
fun textHasFocus(): Boolean {
awaitFrames(2)
var focused = false
instrumentation.runOnMainSync { focused = text.isFocused }
return focused
}
fun selectedText(): String {
awaitFrames(2)
var selected = ""
instrumentation.runOnMainSync {
selected = text.text.substring(text.selectionStart, text.selectionEnd)
}
return selected
}
/** The word under [visibleY] at the current offset. */
fun wordAt(visibleY: Int): String {
var word = ""
instrumentation.runOnMainSync {
val chars = text.text
val y = scroll.scrollY + visibleY - text.top
val offset = text.getOffsetForPosition(TOUCH_X - text.left, y.toFloat())
var start = offset
var end = offset
while (start > 0 && chars[start - 1].isLetterOrDigit()) start--
while (end < chars.length && chars[end].isLetterOrDigit()) end++
word = chars.substring(start, end)
}
assertTrue("The touch lands on a word", word.isNotEmpty())
return word
}
fun settledScrollY(): Int {
var previous = scrollY()
repeat(60) {
awaitFrames(3)
val current = scrollY()
if (current == previous) return current
previous = current
}
throw AssertionError("Offset never settled")
}
private fun scrollY(): Int {
var y = 0
instrumentation.runOnMainSync { y = scroll.scrollY }
return y
}
// Injected through the window like a finger, which puts the window in touch mode.
private fun touch(downTime: Long, action: Int, visibleY: Int) {
val location = IntArray(2)
instrumentation.runOnMainSync { scroll.getLocationOnScreen(location) }
val x = location[0] + TOUCH_X
val y = location[1] + visibleY.toFloat()
val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0)
instrumentation.sendPointerSync(event)
event.recycle()
}
fun awaitFrames(count: Int) {
repeat(count) {
instrumentation.waitForIdleSync()
SystemClock.sleep(16)
}
}
}
/** Which edge of the text is on screen, at [EDGE_Y]; the other is past the viewport. */
private enum class Edge { TOP, BOTTOM }
/** A timeline scrolled to [OFFSET] with one block of chat text taller than the viewport. */
private fun openTimeline(edge: Edge, editable: Boolean = false): 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 text =
if (editable) {
// Selectable, as React Native's TextInput makes itself when attached, so only being a text
// editor tells it apart from chat text. No input connection: building one runs React
// Native's focus search through Fabric, which this context does not run.
object : EditText(context) {
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? = null
}.apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
setTextIsSelectable(true)
}
} else {
// How enriched-markdown and React Native's <Text selectable> set up chat text.
TextView(context).apply { setTextIsSelectable(true) }
}
text.setText(CHAT_TEXT)
content.addView(text)
scroll.addView(content)
started.setContentView(
FrameLayout(started).apply { addView(scroll, FrameLayout.LayoutParams(WIDTH, VIEWPORT)) }
)
// Fabric lays out React content; nothing in a React view group lays out its children itself.
content.layout(0, 0, WIDTH, CONTENT_HEIGHT)
text.measure(exact(WIDTH), unspecified())
assertTrue("The text is taller than the viewport", text.measuredHeight > VIEWPORT)
val top = if (edge == Edge.TOP) OFFSET + EDGE_Y else OFFSET + EDGE_Y - text.measuredHeight
text.layout(0, top, WIDTH, top + text.measuredHeight)
timeline = Timeline(scroll, text)
}
timeline.awaitFrames(2)
timeline.scrollTo(OFFSET)
assertEquals("Starts at the offset", OFFSET, timeline.settledScrollY())
return timeline
}
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
private fun unspecified() = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
private companion object {
const val WIDTH = 600
const val TOUCH_X = WIDTH / 2f
const val VIEWPORT = 800
const val CONTENT_HEIGHT = 6000
const val OFFSET = 2400
// Below the plain activity's action bar, which overlays the top of the window.
const val EDGE_Y = 500
val CHAT_TEXT = List(30) { "Line $it of a reply in the chat timeline." }.joinToString("\n")
}
}
@@ -1,12 +1,10 @@
package sh.paseo.scroll;
import android.content.Context;
import android.graphics.Rect;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.facebook.react.R;
import com.facebook.react.views.scroll.MaintainVisibleScrollPositionHelper;
@@ -14,7 +12,7 @@ import com.facebook.react.views.scroll.ReactScrollView;
import com.facebook.react.views.scroll.ReactScrollViewHelper;
/**
* React Native's vertical scroll view with five corrections. Java, because the content-position
* 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 {
@@ -29,7 +27,6 @@ final class PaseoScrollView extends ReactScrollView {
private @Nullable MaintainVisibleScrollPositionHelper.Config visibleContentPosition = null;
private boolean isClampPending = false;
private boolean isUserDragging = false;
private boolean isFocusRevealSkipped = false;
PaseoScrollView(Context context) {
super(context);
@@ -54,56 +51,6 @@ final class PaseoScrollView extends ReactScrollView {
super.scrollToPreservingMomentum(x, y);
}
// Chat text is selectable (enriched-markdown's text views and <Text selectable>), and selectable
// text takes focus when touched: a tap on a link, a word, or a long-press to select. React Native
// scrolls a newly focused child fully into view, so touching a reply that is partly off screen
// scrolled the timeline by up to a screen under the user's finger. Text the user touched is
// already where they are looking, so its focus does not scroll. Inputs, and focus moved by a
// keyboard, still scroll into view.
@Override
public void requestChildFocus(View child, View focused) {
if (isTouchedReadOnlyText(focused)) {
requestChildFocusWithoutScroll(child, focused);
return;
}
super.requestChildFocus(child, focused);
}
// Focused text asks to reveal its selection whenever the selection changes. A streaming reply
// replaces its text, which collapses the selection to the start, so a reply that kept focus from
// a touch scrolled the timeline back to its first line on the next update. Read-only text has no
// caret, so a collapsed selection has nothing to reveal. A selection the user drags still does.
@Override
public boolean requestChildRectangleOnScreen(View child, Rect rectangle, boolean immediate) {
View focused = findFocus();
if (isTouchedReadOnlyText(focused) && !((TextView) focused).hasSelection()) return false;
return super.requestChildRectangleOnScreen(child, rectangle, immediate);
}
// When the scroll view changes height, Android scrolls the focused view into view again. Touched
// chat text keeps focus, so the composer growing or shrinking below the timeline realigned it.
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
isFocusRevealSkipped = isTouchedReadOnlyText(findFocus());
try {
super.onSizeChanged(w, h, oldw, oldh);
} finally {
isFocusRevealSkipped = false;
}
}
@Override
protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) {
if (isFocusRevealSkipped) return 0;
return super.computeScrollDeltaToGetChildRectOnScreen(rect);
}
private static boolean isTouchedReadOnlyText(@Nullable View view) {
if (!(view instanceof TextView) || !view.isInTouchMode()) return false;
TextView text = (TextView) view;
return text.isTextSelectable() && !text.onCheckIsTextEditor();
}
// The drag begins where React Native emits onScrollBeginDrag.
@Override
protected void handleInterceptedTouchEvent(MotionEvent ev) {
-6
View File
@@ -23,12 +23,6 @@ export default defineConfig({
// spec on one worker avoids repeating its file-level setup across daemons.
fullyParallel: false,
workers: Number(process.env.E2E_WORKERS ?? (process.env.CI ? "2" : "1")),
// Workspace packages are already compiled. Tests import them at run time, so
// on a cold cache every worker transpiles the same files and writes them into
// Playwright's shared transform cache without locking. A worker that reads an
// entry mid-write loads an empty module and fails with "does not provide an
// export named".
build: { external: ["**/packages/*/dist/**"] },
retries: process.env.CI ? 1 : 0,
reporter: [["list"]],
use: {
+2 -4
View File
@@ -81,14 +81,12 @@
/* Suppress the browser's default focus outline — it appears on mouse
clicks and looks out of place against our themed surfaces. Keep a
themed ring for keyboard users via :focus-visible. The ring is the active
theme's `ring` color, exposed by Unistyles as a CSS variable; the fallback
only shows before the theme mounts. */
themed ring for keyboard users via :focus-visible. */
*:focus {
outline: none;
}
*:focus-visible {
outline: 2px solid var(--colors-ring, #20744a);
outline: 2px solid #20744a;
outline-offset: 2px;
}
</style>
+6 -4
View File
@@ -5,7 +5,7 @@ import equal from "fast-deep-equal";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
export const agentCommandQueryPolicy = {
export const sessionCommandQueryPolicy = {
staleTime: 600_000,
gcTime: 1_800_000,
refetchOnMount: true,
@@ -102,12 +102,14 @@ export function draftAgentCommandsQueryKey(input: {
] as const;
}
// Refresh checkout skills after branch changes while keeping the previous catalog usable.
export function invalidateDraftAgentCommandsForCheckout(
// Draft commands include project skills read from the checkout. When the checkout moves to
// another branch the cached list describes files that are gone, so it is dropped rather than
// shown while a refetch runs.
export function resetDraftAgentCommandsForCheckout(
queryClient: QueryClient,
input: { serverId: string; cwd: string },
): Promise<void> {
return queryClient.invalidateQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
return queryClient.resetQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
}
export function agentCommandsQueryKey(input: {
+3 -36
View File
@@ -2,13 +2,12 @@ import { describe, expect, it } from "vitest";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { fetchQueryOptions } from "@/data/query";
import {
invalidateDraftAgentCommandsForCheckout,
agentCommandsQueryKey,
agentCommandsQueryRoot,
draftAgentCommandsQueryKey,
normalizeAgentCommandsCwd,
sessionAgentCommandsQueryKey,
agentCommandQueryPolicy,
sessionCommandQueryPolicy,
} from "./cache";
it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => {
@@ -21,8 +20,8 @@ it("reuses fresh commands and keeps stale commands during a shared refresh and f
resolveRefresh = resolve;
});
const options = fetchQueryOptions({
...agentCommandQueryPolicy,
staleTimeMs: agentCommandQueryPolicy.staleTime,
...sessionCommandQueryPolicy,
staleTimeMs: sessionCommandQueryPolicy.staleTime,
dataShape: "value",
queryKey: key,
retry: false,
@@ -125,35 +124,3 @@ describe("agent command query keys", () => {
expect(agentCommandsQueryRoot("server-1")).toEqual(["agentCommands", "server-1"]);
});
});
it("retains draft commands while checkout invalidation refreshes the catalog", async () => {
const client = new QueryClient();
const key = draftAgentCommandsQueryKey({
serverId: "s",
draftConfig: { provider: "codex", cwd: "/repo" },
});
client.setQueryData(key, ["old"]);
let finish: (commands: string[]) => void = () => {};
const refresh = new Promise<string[]>((resolve) => {
finish = resolve;
});
const observer = new QueryObserver(
client,
fetchQueryOptions({
queryKey: key,
dataShape: "value",
immutableWhen: () => true,
queryFn: () => refresh,
}),
);
const unsubscribe = observer.subscribe(() => {});
const updating = invalidateDraftAgentCommandsForCheckout(client, { serverId: "s", cwd: "/repo" });
expect(observer.getCurrentResult().data).toEqual(["old"]);
expect(observer.getCurrentResult().isPending).toBe(false);
expect(observer.getCurrentResult().isFetching).toBe(true);
finish(["new"]);
await updating;
expect(observer.getCurrentResult().data).toEqual(["new"]);
unsubscribe();
client.clear();
});
+4 -3
View File
@@ -9,7 +9,7 @@ import { useShallow } from "zustand/shallow";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
agentCommandsQueryKey,
agentCommandQueryPolicy,
sessionCommandQueryPolicy,
type AgentCommandsDraftConfig,
} from "./cache";
@@ -92,8 +92,9 @@ export function useAgentCommandsQuery({
agentCommandProvider: draftConfig?.provider ?? provider,
agentCommandCwd: draftConfig?.cwd ?? cwd,
},
...agentCommandQueryPolicy,
staleTimeMs: agentCommandQueryPolicy.staleTime,
...(draftConfig
? { immutableWhen: () => true as const }
: { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }),
subscribed: queryEnabled,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
@@ -49,21 +49,8 @@ afterEach(() => {
host.remove();
});
type OlderHistoryInput = Pick<
StreamRenderInput,
"onNearHistoryStart" | "hasOlderHistory" | "olderHistoryProgressKey"
>;
interface MountTimelineOptions {
olderHistory?: OlderHistoryInput;
isMobileBreakpoint?: boolean;
}
function mountTimeline(
historyMounted: StreamItem[],
{ olderHistory, isMobileBreakpoint = true }: MountTimelineOptions = {},
) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint });
function mountTimeline(historyMounted: StreamItem[]) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const input: StreamRenderInput = {
agentId: "agent",
@@ -89,7 +76,6 @@ function mountTimeline(
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
...olderHistory,
};
const render = (segments: Partial<StreamRenderInput["segments"]>) =>
root.render(strategy.render({ ...input, segments: { ...input.segments, ...segments } }));
@@ -114,20 +100,8 @@ function mountTimeline(
scrollTo: (top: number) => {
scrollContainer.scrollTop = top;
},
// A wheel listener runs before the browser applies the scroll the wheel causes.
wheelUpTo: (top: number) => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
scrollContainer.scrollTop = top;
},
distanceFromEnd: () =>
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
scrollTop: () => scrollContainer.scrollTop,
scrollUp: (distance: number) =>
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -distance }));
scrollContainer.scrollTop -= distance;
scrollContainer.dispatchEvent(new Event("scroll"));
}),
// Resize observers and the hold's settle run on animation frames.
settle: () =>
act(async () => {
@@ -139,53 +113,6 @@ function mountTimeline(
}
describe("web disclosure hold", () => {
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
"keeps the reading position when mounted history becomes virtualized (compact: $isMobileBreakpoint)",
async (options) => {
const history = Array.from({ length: 80 }, (_, index) => row(index));
const timeline = mountTimeline(history, options);
await timeline.settle();
timeline.scrollUp(600);
await timeline.settle();
const offset = timeline.scrollTop();
const readingTop = timeline.rowTop("message-64");
expect(offset).toBeGreaterThan(0);
timeline.render({
historyVirtualized: history.slice(0, 60),
historyMounted: history.slice(60),
});
await timeline.settle();
expect(timeline.scrollTop()).toBe(offset);
expect(timeline.rowTop("message-64")).toBe(readingTop);
},
);
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
"keeps following output when mounted history becomes virtualized at the end (compact: $isMobileBreakpoint)",
async (options) => {
const history = Array.from({ length: 80 }, (_, index) => row(index));
const timeline = mountTimeline(history, options);
await timeline.settle();
timeline.render({
historyVirtualized: history.slice(0, 60),
historyMounted: history.slice(60),
});
await timeline.settle();
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
timeline.render({
historyVirtualized: history.slice(0, 60),
historyMounted: history.slice(60),
liveHead: [row(80)],
});
await timeline.settle();
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
},
);
it("keeps the trigger's top when an anchored toggle at the bottom opens rows below it, and stops following", async () => {
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history);
@@ -281,32 +208,3 @@ describe("web disclosure hold", () => {
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
});
});
describe("web history start", () => {
it("loads older history when an upward wheel lands at the start after the reader stopped following", async () => {
let olderHistoryLoads = 0;
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history, {
olderHistory: {
hasOlderHistory: true,
olderHistoryProgressKey: "epoch:1",
onNearHistoryStart: async () => {
olderHistoryLoads += 1;
return true;
},
},
});
await timeline.settle();
// Opening a row at the bottom stops following output without any scroll.
timeline.hold("message-5", () =>
timeline.render({ historyMounted: [...history.slice(0, 5), row(7, 200), history[5]!] }),
);
await timeline.settle();
expect(olderHistoryLoads).toBe(0);
timeline.wheelUpTo(0);
await timeline.settle();
expect(olderHistoryLoads).toBe(1);
});
});
+6 -19
View File
@@ -457,8 +457,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
enabled: shouldUseVirtualizer,
// History can become virtualized after the reader has already scrolled the mounted rows.
initialOffset: () => scrollContainerRef.current?.scrollTop ?? 0,
initialRect: {
width: scrollContainerRef.current?.clientWidth ?? 0,
height: scrollContainerRef.current?.clientHeight ?? 0,
@@ -607,15 +605,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
}, []);
const hasUpwardInputEvidence = useCallback(() => {
const mouseGesture = mouseScrollGestureRef.current;
return (
mouseGesture?.kind === "scrollbar" ||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
window.performance.now() < upwardInputEvidenceUntilRef.current
);
}, []);
useLayoutEffect(() => {
if (isActive) {
return;
@@ -802,16 +791,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
const scrolledDown =
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
const userScrolledUp = scrolledUp && hasUpwardInputEvidence();
const mouseGesture = mouseScrollGestureRef.current;
const hasUpwardInputEvidence =
mouseGesture?.kind === "scrollbar" ||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
window.performance.now() < upwardInputEvidenceUntilRef.current;
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
setFollowOutput(true);
} else if (followOutputRef.current && userScrolledUp) {
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
stopFollowingOutputFromUserIntent();
} else if (userScrolledUp) {
// Wheel listeners can run before or after the browser applies the scroll, so the
// upward scroll itself re-arms loading when it reaches the start.
rearmHistoryStartFromUserIntent();
}
lastKnownScrollTopRef.current = currentScrollTop;
@@ -822,10 +811,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
evaluateHistoryStart();
}, [
evaluateHistoryStart,
hasUpwardInputEvidence,
isJumpSettling,
readingAnchor,
rearmHistoryStartFromUserIntent,
stopFollowingOutputFromUserIntent,
updateScrollMetrics,
]);
+10 -57
View File
@@ -87,10 +87,7 @@ import {
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import {
useSettingsRouteEntry,
useSettingsSidebarPresentation,
} from "@/navigation/settings-presentation";
import { useSettingsRouteEntry } from "@/navigation/settings-presentation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
@@ -476,16 +473,12 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
viewportWidth,
}),
});
const { presentation: sidebarPresentation, visible: sidebarVisible } =
useSettingsSidebarPresentation({
visible: chromeEnabled,
presentation: resolveLeftSidebarPresentation({
chromeEnabled,
isCompactLayout,
isFocusModeEnabled: isWorkspaceFocusModeEnabled,
desktopSidebarVisible,
}),
});
const sidebarPresentation = resolveLeftSidebarPresentation({
chromeEnabled,
isCompactLayout,
isFocusModeEnabled: isWorkspaceFocusModeEnabled,
desktopSidebarVisible,
});
const applyPanelLayout = usePanelStore((state) => state.applyPanelLayout);
// Publishes the layout to the store, which owns the panel targets each layout allows.
useLayoutEffect(() => {
@@ -553,11 +546,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<DesktopWindowControls />
<FloatingPanelPortalHost />
</AppearanceStyleBoundary>
{isCompactLayout ? (
<CompactSidebarHost visible={sidebarVisible} interactive={chromeEnabled}>
{themedSidebarChrome}
</CompactSidebarHost>
) : null}
{isCompactLayout ? themedSidebarChrome : null}
<AppearanceStyleBoundary>
<DownloadToast />
<RosettaCalloutSource />
@@ -595,34 +584,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
);
}
// Keep native scroll ranges and gesture hosts intact while Settings covers the sidebar.
function CompactSidebarHost({
visible,
interactive,
children,
}: {
visible: boolean;
interactive: boolean;
children: ReactNode;
}) {
return (
<View
collapsable={false}
style={[
layoutStyles.compactSidebarHost,
{ opacity: visible ? 1 : 0 },
// DOM descendants can override pointer-events:none.
isWeb && !visible && { display: "none" },
]}
pointerEvents={interactive ? "box-none" : "none"}
accessibilityElementsHidden={!interactive}
importantForAccessibility={interactive ? "auto" : "no-hide-descendants"}
>
{children}
</View>
);
}
function AppContainerLifetime({
children,
sidebarGesturesEnabled,
@@ -653,18 +614,14 @@ function SidebarChrome({
const isCompactLayout = useIsCompactFormFactor();
const isMobileActive = useIsMobilePanelActive("agent-list");
const overlay = presentation === "overlay";
const active = resolveSidebarActivity(
presentation !== "hidden" && presentation !== "suspended",
overlay,
isMobileActive,
);
const active = resolveSidebarActivity(presentation !== "hidden", overlay, isMobileActive);
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
return (
<SidebarModelProvider
active={active}
keyboardNavigationActive={keyboardShortcutsEnabled && hardwareKeyboardPresent}
>
{mounted || presentation === "suspended" ? (
{mounted ? (
<LeftSidebar active={active} desktopOverlay={overlay && !isCompactLayout} />
) : null}
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
@@ -907,8 +864,6 @@ const ROOT_STACK_SCREEN_OPTIONS = {
animation: "none" as const,
};
const ROOT_STACK_NESTED_NAVIGATOR_SCREENS = ["h/[serverId]"] as const;
// Settings owns reactive typography leaves; its scroll and form state survive appearance edits.
const ROOT_STACK_SCREEN_OWNED_APPEARANCE = ["settings/[section]"] as const;
function RootStack() {
const storeReady = useStoreReady();
@@ -916,7 +871,6 @@ function RootStack() {
<ThemedStack
screenOptions={ROOT_STACK_SCREEN_OPTIONS}
nestedNavigatorScreens={ROOT_STACK_NESTED_NAVIGATOR_SCREENS}
screenOwnedAppearance={ROOT_STACK_SCREEN_OWNED_APPEARANCE}
>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
@@ -1050,7 +1004,6 @@ export default function RootLayout() {
}
const layoutStyles = StyleSheet.create((theme) => ({
compactSidebarHost: { position: "absolute", top: 0, bottom: 0, left: 0, right: 0 },
surfaceFill: {
flex: 1,
backgroundColor: theme.colors.surface0,
-3
View File
@@ -1,3 +0,0 @@
import { Text } from "react-native";
export { Text as AppearanceText };
-12
View File
@@ -1,12 +0,0 @@
import { Text as NativeText } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
// Web numeric tokens are baked into classes. Subscribe this leaf so withUnistyles
// resolves its style prop again without replacing the surrounding form or scroll view.
function typographyChanges(theme: Theme) {
void theme.fontSize;
return {};
}
export const AppearanceText = withUnistyles(NativeText, typographyChanges);
+1 -2
View File
@@ -1,7 +1,6 @@
import { AppearanceText as Text } from "@/appearance/text";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, type PressableStateCallbackType } from "react-native";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { PressHighlight } from "@/components/ui/press-highlight";
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
@@ -1,5 +0,0 @@
export type { ArchivedWorkspace, ArchivedWorkspaceAgent } from "./internal/group";
export {
useArchivedWorkspaceSearch,
type ArchivedWorkspaceSearch,
} from "./internal/use-archived-workspace-search";
@@ -1,128 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { groupArchivedWorkspaces } from "./group";
const T0 = Date.parse("2026-11-01T10:00:00.000Z");
const minutes = (count: number) => new Date(T0 + count * 60_000);
function historyAgent(input: {
id: string;
serverId?: string;
workspaceId?: string | null;
title?: string | null;
activeAt: number;
archivedAt?: number | null;
workspaceName?: string | null;
branch?: string | null;
cwd?: string;
}): AggregatedAgent {
const cwd = input.cwd ?? "/repos/payments";
return {
id: input.id,
serverId: input.serverId ?? "host-a",
serverLabel: input.serverId ?? "host-a",
title: input.title ?? null,
status: "closed",
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
lastActivityAt: minutes(input.activeAt),
cwd,
workspaceId: input.workspaceId === undefined ? "ws-1" : input.workspaceId,
provider: "claude",
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: input.archivedAt == null ? null : minutes(input.archivedAt),
createdAt: minutes(0),
labels: {},
projectPlacement: {
projectKey: "prj-payments",
projectName: "payments",
workspaceName: input.workspaceName === undefined ? "Billing cleanup" : input.workspaceName,
checkout: {
cwd,
isGit: true,
currentBranch: input.branch === undefined ? "billing-cleanup" : input.branch,
remoteUrl: null,
worktreeRoot: cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
} as AggregatedAgent;
}
const NO_ACTIVE = new Map<string, ReadonlySet<string>>();
describe("groupArchivedWorkspaces", () => {
it("folds a workspace's agents into one entry led by its most recent agent", () => {
const [workspace] = groupArchivedWorkspaces({
agents: [
historyAgent({ id: "older", title: "Audit invoices", activeAt: 1, archivedAt: 5 }),
historyAgent({ id: "newer", title: "Fix totals", activeAt: 3, archivedAt: 4 }),
],
activeIds: NO_ACTIVE,
});
expect(workspace).toMatchObject({
key: "host-a:ws-1",
serverId: "host-a",
workspaceId: "ws-1",
name: "Billing cleanup",
projectName: "payments",
projectKey: "prj-payments",
branch: "billing-cleanup",
archivedAt: minutes(5),
});
expect(workspace.agents.map((agent) => agent.id)).toEqual(["newer", "older"]);
});
it("leaves out workspaces that are still active on their host", () => {
const workspaces = groupArchivedWorkspaces({
agents: [historyAgent({ id: "a", activeAt: 1, archivedAt: 2 })],
activeIds: new Map([["host-a", new Set(["ws-1"])]]),
});
expect(workspaces).toEqual([]);
});
it("leaves out an inactive workspace none of whose agents were archived", () => {
const workspaces = groupArchivedWorkspaces({
agents: [historyAgent({ id: "a", activeAt: 1, archivedAt: null })],
activeIds: NO_ACTIVE,
});
expect(workspaces).toEqual([]);
});
it("keeps the same workspace ID on two hosts apart and lists the latest archive first", () => {
const workspaces = groupArchivedWorkspaces({
agents: [
historyAgent({ id: "a", serverId: "host-a", activeAt: 1, archivedAt: 2 }),
historyAgent({ id: "b", serverId: "host-b", activeAt: 1, archivedAt: 9 }),
],
activeIds: NO_ACTIVE,
});
expect(workspaces.map((workspace) => workspace.key)).toEqual(["host-b:ws-1", "host-a:ws-1"]);
});
it("skips agents with no workspace and names an unnamed workspace after its folder", () => {
const workspaces = groupArchivedWorkspaces({
agents: [
historyAgent({ id: "loose", workspaceId: null, activeAt: 1, archivedAt: 2 }),
historyAgent({
id: "a",
workspaceName: null,
branch: null,
cwd: "/repos/receipts",
activeAt: 1,
archivedAt: 2,
}),
],
activeIds: NO_ACTIVE,
});
expect(workspaces).toHaveLength(1);
expect(workspaces[0]).toMatchObject({ name: "receipts", branch: null });
});
});
@@ -1,87 +0,0 @@
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
export interface ArchivedWorkspaceAgent {
id: string;
title: string | null;
provider: string;
}
/** A workspace the user archived, as agent history describes it. */
export interface ArchivedWorkspace {
/** `serverId:workspaceId` */
key: string;
serverId: string;
workspaceId: string;
name: string;
projectName: string | null;
/** The host's project ID. */
projectKey: string | null;
branch: string | null;
/** The latest archive time among its agents. */
archivedAt: Date;
/** Its agents in this history page, most recently active first. Never empty. */
agents: ArchivedWorkspaceAgent[];
}
/** Which workspaces each host still lists as active. */
export type ActiveWorkspaceIdsByServer = ReadonlyMap<string, ReadonlySet<string>>;
function lastPathSegment(path: string): string {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] ?? path;
}
function checkoutBranch(agent: AggregatedAgent): string | null {
const checkout = agent.projectPlacement?.checkout;
return checkout?.isGit ? checkout.currentBranch : null;
}
/**
* Folds agent history into one entry per archived workspace, newest archive first. A
* workspace its host still lists is active, so it is left out. So is an inactive workspace
* with no archived agent: the user never put it away (a background workspace, for example).
*/
export function groupArchivedWorkspaces(input: {
agents: readonly AggregatedAgent[];
activeIds: ActiveWorkspaceIdsByServer;
}): ArchivedWorkspace[] {
const byKey = new Map<string, ArchivedWorkspace & { archivedAtOrNull: Date | null }>();
const newestFirst = [...input.agents].sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
for (const agent of newestFirst) {
const workspaceId = agent.workspaceId;
if (!workspaceId || input.activeIds.get(agent.serverId)?.has(workspaceId)) continue;
const key = `${agent.serverId}:${workspaceId}`;
const archivedAt = agent.archivedAt ? new Date(agent.archivedAt) : null;
const summary = { id: agent.id, title: agent.title ?? null, provider: agent.provider };
const existing = byKey.get(key);
if (existing) {
existing.agents.push(summary);
if (archivedAt && (!existing.archivedAtOrNull || archivedAt > existing.archivedAtOrNull)) {
existing.archivedAtOrNull = archivedAt;
}
continue;
}
// The most recent agent describes the workspace: it saw its latest name and branch.
const placement = agent.projectPlacement;
byKey.set(key, {
key,
serverId: agent.serverId,
workspaceId,
name: placement?.workspaceName || lastPathSegment(agent.cwd),
projectName: placement?.projectName ?? null,
projectKey: placement?.projectKey ?? null,
branch: checkoutBranch(agent),
archivedAt: archivedAt ?? new Date(0),
archivedAtOrNull: archivedAt,
agents: [summary],
});
}
const archived: ArchivedWorkspace[] = [];
for (const { archivedAtOrNull, ...workspace } of byKey.values()) {
if (archivedAtOrNull) archived.push({ ...workspace, archivedAt: archivedAtOrNull });
}
return archived.sort((left, right) => right.archivedAt.getTime() - left.archivedAt.getTime());
}
@@ -1,83 +0,0 @@
import { useMemo } from "react";
import { useShallow } from "zustand/shallow";
import { useAgentHistory } from "@/hooks/use-agent-history";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
groupArchivedWorkspaces,
type ActiveWorkspaceIdsByServer,
type ArchivedWorkspace,
} from "./group";
const SEARCH_DEBOUNCE_MS = 200;
const NO_WORKSPACES: Record<string, ReadonlyMap<string, WorkspaceDescriptor>> = {};
const NO_ARCHIVED_WORKSPACES: ArchivedWorkspace[] = [];
export interface ArchivedWorkspaceSearch {
/** Newest archive first. Empty without a query. */
workspaces: ArchivedWorkspace[];
/**
* The host matched these for the current query. Its search forgives typos, so its matches
* can include rows the caller cannot match itself. False while the previous query's
* results stand in for the next one; those rows must still match the query on their own.
*/
matchedByHost: boolean;
/** Still searching for the current query, debounce included. */
pending: boolean;
}
function useActiveWorkspaceIds(enabled: boolean): ActiveWorkspaceIdsByServer {
// Each host's workspace map is replaced only when its workspaces change, so this does not
// re-render on agent activity.
const workspacesByServer = useSessionStore(
useShallow((state) =>
enabled
? Object.fromEntries(
Object.entries(state.sessions).map(([serverId, session]) => [
serverId,
session.workspaces,
]),
)
: NO_WORKSPACES,
),
);
return useMemo(
() =>
new Map(
Object.entries(workspacesByServer).map(([serverId, workspaces]) => [
serverId,
new Set(workspaces.keys()),
]),
),
[workspacesByServer],
);
}
/**
* Archived workspaces whose name, project, branch, or agent titles match `query`, searched on
* every host that supports history search. Hosts that predate it contribute nothing.
*/
export function useArchivedWorkspaceSearch(
query: string,
options: { enabled: boolean },
): ArchivedWorkspaceSearch {
const trimmed = query.trim();
const search = useDebouncedValue(trimmed, SEARCH_DEBOUNCE_MS);
const enabled = options.enabled && trimmed.length > 0;
const activeIds = useActiveWorkspaceIds(enabled);
const history = useAgentHistory({
search,
enabled: enabled && search.length > 0,
searchableHostsOnly: true,
keepPreviousResults: true,
});
const workspaces = useMemo(
() =>
enabled
? groupArchivedWorkspaces({ agents: history.agents, activeIds })
: NO_ARCHIVED_WORKSPACES,
[activeIds, enabled, history.agents],
);
const matchedByHost = search === trimmed && !history.isLoading && !history.isPreviousResults;
return { workspaces, matchedByHost, pending: enabled && !matchedByHost };
}
@@ -39,21 +39,6 @@ function createDeferred<T>() {
return { promise, resolve, reject };
}
/**
* Resolves a pending lookup and waits until React Query has delivered the result. It delivers on a
* timer, which would otherwise fire after the test, when jsdom is gone.
*/
async function resolveLookupAndDeliver(
lookup: ReturnType<typeof createDeferred<DirectorySuggestionResult>>,
result: DirectorySuggestionResult,
): Promise<void> {
await act(async () => {
lookup.resolve(result);
await lookup.promise;
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
@@ -337,12 +322,11 @@ describe("useFileLink", () => {
act(() => {
result.current.setWorkspaceRoot("/Users/test/other");
});
await resolveLookupAndDeliver(
deferred,
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
);
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
expect(openedFiles).toEqual([]);
});
});
@@ -759,56 +759,6 @@ describe("assistant selection copy inside highlighted code", () => {
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
);
});
it("keeps line breaks when a quote selection ends partway through code", () => {
const message = mountHighlighted();
const quote = document.createElement("div");
quote.setAttribute("data-paseo-markdown-tag", "blockquote");
quote.textContent = "Quoted context";
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(quote);
const content = copyBetween(
[tokenText(message, "Quoted context"), 0],
[tokenText(message, " (answer)"), 9],
);
expect(content?.plainText).toBe(
"> Quoted context\n\n```typescript\nconst answer = 1;\n if (answer)\n```",
);
});
it("keeps indented code lines inside their list item", () => {
const host = document.createElement("div");
host.innerHTML = [
'<div data-testid="assistant-message">',
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
'<div data-paseo-markdown-tag="li">',
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
'<div><div data-paseo-markdown-tag="p"><span>Item text</span></div>',
highlightedCodeBlock("typescript"),
"</div></div></div></div>",
].join("");
document.body.append(host);
const content = copyBetween([tokenText(host, "1."), 0], [tokenText(host, " (answer)"), 9]);
expect(content?.plainText).toBe(
"1. Item text\n\n ```typescript\n const answer = 1;\n if (answer)\n ```",
);
});
it("keeps line breaks when a selection starts partway through code", () => {
const message = mountHighlighted();
const content = copyBetween(
[tokenText(message, " answer"), 1],
[tokenText(message, "After the block."), "After the block.".length],
);
expect(content?.plainText).toBe(
"```typescript\nanswer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
);
});
});
describe("semantic library output", () => {
@@ -81,8 +81,7 @@ turndown.addRule("compactListItem", {
const item = content
.replace(/^\n+/, "")
.replace(/\n+$/, "\n")
// Every non-empty line, so indented code lines stay inside the item.
.replace(/\n(?=[^\n])/g, "\n ");
.replace(/\n(?=\S)/g, "\n ");
const parent = node.parentElement;
if (parent?.nodeName !== "OL") {
return `${options.bulletListMarker} ${item}\n`;
@@ -360,9 +359,6 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
return true;
}
if (isCrossBoundaryCodeElement(range, element, tag)) {
return true;
}
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
if (
(isSelectableSemantic || element.tagName === "A") &&
@@ -390,11 +386,6 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
return hasSelectedAllContents(range, element, tag === "li");
}
function isCrossBoundaryCodeElement(range: Range, element: Element, tag: string | null): boolean {
const isCode = tag === "pre" || (tag === "code" && Boolean(element.closest(CODE_BLOCK_SELECTOR)));
return isCode && !selectionStaysInsideElement(range, element);
}
function selectionStaysInsideElement(range: Range, element: Element): boolean {
return (
range.startContainer !== element &&
@@ -1,71 +0,0 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { persistAttachmentFromBlob } from "@/attachments/service";
import type { AttachmentMetadata } from "@/attachments/types";
import { type AttachmentPreviewUrl, useAttachmentPreviewUrl } from "./use-attachment-preview-url";
let root: Root;
let container: HTMLDivElement;
let latest: AttachmentPreviewUrl | null = null;
function PreviewProbe({ attachment }: { attachment: AttachmentMetadata | null }) {
latest = useAttachmentPreviewUrl(attachment);
return null;
}
async function render(attachment: AttachmentMetadata | null) {
await act(async () => root.render(<PreviewProbe attachment={attachment} />));
}
async function waitForReady(): Promise<string> {
await vi.waitFor(() => expect(latest?.status).toBe("ready"));
if (latest?.status !== "ready") throw new Error("preview never resolved");
return latest.url;
}
beforeEach(() => {
vi.stubGlobal("React", React);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
latest = null;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it("never hands back a URL it already released when the same attachment returns", async () => {
const attachment = await persistAttachmentFromBlob({
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
mimeType: "image/png",
fileName: "shot.png",
});
await render(attachment);
const released = await waitForReady();
await render(null);
await render(attachment);
expect(latest).not.toEqual({ status: "ready", url: released });
expect(await waitForReady()).not.toBe(released);
});
it("retries a failed attachment from loading when it returns", async () => {
const stored = await persistAttachmentFromBlob({
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
mimeType: "image/png",
fileName: "shot.png",
});
const missing = { ...stored, id: "missing-attachment", storageKey: "missing-attachment" };
await render(missing);
await vi.waitFor(() => expect(latest?.status).toBe("failed"));
await render(null);
await render(missing);
expect(latest).toEqual({ status: "loading" });
});
@@ -2,75 +2,62 @@ import { useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
export type AttachmentPreviewUrl =
| { status: "loading" }
| { status: "ready"; url: string }
| { status: "failed" };
const LOADING: AttachmentPreviewUrl = { status: "loading" };
const FAILED: AttachmentPreviewUrl = { status: "failed" };
function attachmentKey(attachment: AttachmentMetadata | null | undefined): string | null {
if (!attachment) return null;
return [attachment.id, attachment.storageType, attachment.storageKey, attachment.mimeType].join(
"\u0000",
);
}
/**
* Resolves a displayable URL for a stored attachment. Stays `loading` until the URL for the
* current attachment resolves, so a caller never shows a failure, or the previous attachment's
* URL, while the next one is still resolving. A missing attachment stays `loading`.
*/
export function useAttachmentPreviewUrl(
attachment: AttachmentMetadata | null | undefined,
): AttachmentPreviewUrl {
const [resolved, setResolved] = useState<{
key: string;
preview: AttachmentPreviewUrl;
} | null>(null);
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
const attachmentRef = useRef(attachment);
attachmentRef.current = attachment;
const key = attachmentKey(attachment);
const id = attachment?.id;
const storageType = attachment?.storageType;
const storageKey = attachment?.storageKey;
const mimeType = attachment?.mimeType;
useEffect(() => {
const current = attachmentRef.current;
if (!current || key === null) {
return;
}
let disposed = false;
let currentUrl: string | null = null;
const current = attachmentRef.current;
activeAttachmentRef.current = current ?? null;
if (!current) {
setUrl(null);
return;
}
void (async () => {
try {
const url = await resolveAttachmentPreviewUrl(current);
const resolved = await resolveAttachmentPreviewUrl(current);
if (disposed) {
await releaseAttachmentPreviewUrl({ attachment: current, url });
await releaseAttachmentPreviewUrl({ attachment: current, url: resolved });
return;
}
currentUrl = url;
setResolved({ key, preview: { status: "ready", url } });
currentUrl = resolved;
setUrl(resolved);
} catch (error) {
console.error("[attachments] Failed to resolve preview URL", {
attachmentId: current.id,
error,
});
if (!disposed) {
setResolved({ key, preview: FAILED });
setUrl(null);
}
}
})();
return () => {
disposed = true;
if (currentUrl) {
void releaseAttachmentPreviewUrl({ attachment: current, url: currentUrl });
const activeAttachment = activeAttachmentRef.current;
if (!currentUrl || !activeAttachment) {
return;
}
// A result lives only as long as its lookup. Kept, the same attachment coming back would
// read a released URL, or an old failure, until the next lookup lands.
setResolved((previous) => (previous?.key === key ? null : previous));
void releaseAttachmentPreviewUrl({
attachment: activeAttachment,
url: currentUrl,
});
};
}, [key]);
}, [id, storageType, storageKey, mimeType]);
return resolved !== null && resolved.key === key ? resolved.preview : LOADING;
return url;
}
@@ -1,18 +1,18 @@
import { memo, useCallback, useMemo, useState } from "react";
import { Text, View } from "react-native";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ExternalLink, Gift } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
AdaptiveModalSheet,
SheetHeaderIconButton,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
iconButtonChromeGlyphSize,
iconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
import { StatusBadge } from "@/components/ui/status-badge";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolveAppVersion } from "@/utils/app-version";
@@ -28,9 +28,12 @@ import {
const WEBSITE_CHANGELOG_URL = "https://paseo.sh/changelog";
const ThemedGift = withUnistyles(Gift);
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const sheetLeadingIcon = <ThemedGift size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
const websiteButtonStyle = (state: PressableStateCallbackType & { hovered?: boolean }) =>
iconButtonChromeStyle({ size: "large", state });
interface ChangelogSheetProps {
visible: boolean;
@@ -51,12 +54,19 @@ export function ChangelogSheet({ visible, onClose }: ChangelogSheetProps) {
title: t("changelog.title"),
leading: sheetLeadingIcon,
actions: (
<SheetHeaderIconButton
icon={ExternalLink}
label={t("changelog.openWebsite")}
<Pressable
onPress={handleOpenWebsite}
hitSlop={8}
style={websiteButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("changelog.openWebsite")}
testID="changelog-open-website"
/>
>
<ThemedExternalLink
size={iconButtonChromeGlyphSize("large")}
uniProps={mutedColorMapping}
/>
</Pressable>
),
}),
[handleOpenWebsite, t],
+293 -410
View File
@@ -24,12 +24,10 @@ import {
useSyncExternalStore,
} from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight } from "lucide-react-native";
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
import { resolveAgentLabel } from "@/agent-presentation";
import { useArchivedWorkspaceSearch, type ArchivedWorkspace } from "@/archived-workspaces";
import { ArchivedBadge } from "@/components/archived-badge";
import { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation";
import { StatusIcon } from "@/components/status-icon";
import { useWorkspaceStatus } from "@/workspace-status";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
@@ -37,14 +35,12 @@ import type { Theme } from "@/styles/theme";
import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text";
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { useProviderIcon } from "@/components/provider-icons";
import { ProjectIconView } from "@/components/project-icon-view";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { Shortcut } from "@/components/ui/shortcut";
import {
IsolatedBottomSheetModal,
@@ -52,20 +48,18 @@ import {
} from "@/components/ui/isolated-bottom-sheet-modal";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { createProjectIconTarget } from "@/projects/icon-target";
import { useProjectIcons } from "@/projects/icons";
import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root";
import { useHostRuntimeConnectionStatuses, useHosts } from "@/runtime/host-runtime";
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { useHosts } from "@/runtime/host-runtime";
import {
navigateToWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
useKeyboardShortcutsStore,
type CommandCenterScope,
} from "@/stores/keyboard-shortcuts-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import {
clearCommandCenterFocusRestoreElement,
@@ -73,8 +67,8 @@ import {
} from "@/utils/command-center-focus-restore";
import { focusWithRetries } from "@/utils/web-focus";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { shortenPath } from "@/utils/shorten-path";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { getProjectSummaryForHostProject } from "@/utils/projects";
import {
COMMAND_CENTER_LINE_HEIGHT,
COMMAND_CENTER_LIST_METRICS,
@@ -82,23 +76,21 @@ import {
type CommandCenterListMetrics,
} from "./list-metrics";
import { useCommandCenterContributions } from "./provider";
import { createWorkspaceRanker } from "./workspace-search";
import { filterAndRankWorkspaces } from "./workspace-search";
import {
buildContributionSections,
filterAndRankBuiltInResults,
joinSubtitleParts,
PINNED_SECTION_BAND,
projectCommandCenterRows,
type CommandCenterAgentResult,
type CommandCenterFileResult,
type CommandCenterListRow,
type CommandCenterResult,
type CommandCenterWorkspaceAgent,
type CommandCenterResultSection,
type CommandCenterSearchFields,
type CommandCenterWorkspaceResult,
} from "./results";
import { buildCommandCenterSections } from "./sections";
import {
availableCommandCenterTabs,
resolveCommandCenterTab,
rotateCommandCenterTab,
type CommandCenterTab,
} from "./tabs";
import { createCommandCenterInteraction, type CommandCenterInteraction } from "./interaction";
import { useWorkspaceFileSearch } from "./workspace-file-search";
@@ -108,16 +100,23 @@ const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreground }));
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
/** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */
function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearchFields {
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
}
function createWorkspaceResultAction(serverId: string, workspaceId: string) {
return () => {
clearCommandCenterFocusRestoreElement();
@@ -125,73 +124,28 @@ function createWorkspaceResultAction(serverId: string, workspaceId: string) {
};
}
function createAgentResultAction(serverId: string) {
return (agentId: string) => {
function createAgentResultAction(serverId: string, agentId: string) {
return () => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId, agentId });
};
}
const NO_WORKSPACE_AGENTS: readonly CommandCenterWorkspaceAgent[] = [];
/** Each workspace's titled agents, most recent first, keyed `serverId:workspaceId`. */
function groupWorkspaceAgents(
agents: readonly AggregatedAgent[],
): Map<string, CommandCenterWorkspaceAgent[]> {
const byWorkspace = new Map<string, CommandCenterWorkspaceAgent[]>();
const newestFirst = [...agents].sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
for (const agent of newestFirst) {
const title = resolveAgentLabel(agent.title);
if (!agent.workspaceId || !title) continue;
const key = `${agent.serverId}:${agent.workspaceId}`;
const list = byWorkspace.get(key) ?? [];
list.push({ id: agent.id, title, provider: agent.provider });
byWorkspace.set(key, list);
}
return byWorkspace;
}
function createArchivedAgentAction(serverId: string, workspaceId: string) {
// Opening on an agent with the tab pinned is what offers Unarchive, as History does.
return (agentId: string) => {
clearCommandCenterFocusRestoreElement();
navigateToAgent({ serverId, agentId, workspaceId, pin: true });
};
}
function titledAgents(
agents: readonly { id: string; title: string | null; provider: string }[],
): CommandCenterWorkspaceAgent[] {
return agents.flatMap((agent) => {
const title = resolveAgentLabel(agent.title);
return title ? [{ id: agent.id, title, provider: agent.provider }] : [];
});
}
interface WorkspaceRows {
active: CommandCenterWorkspaceResult[];
archived: CommandCenterWorkspaceResult[];
/** The workspace list has not loaded yet, so rows are still arriving. */
loading: boolean;
}
/**
* Build every workspace row. Deliberately not keyed on `query`: none of this work depends on
* what was typed. Ordering belongs to the workspace ranker, which ranks on every keystroke.
* Archived rows come from the host's history search, so they change with its results.
* Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what
* was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke.
*/
function useWorkspaceRows(
open: boolean,
archivedWorkspaces: readonly ArchivedWorkspace[],
): WorkspaceRows {
const previousRows = useRef(new Map<string, CommandCenterWorkspaceResult>());
const { agents, isInitialLoad: agentsLoading } = useAggregatedAgents({ demand: open });
const { projects, isLoading: projectsLoading } = useProjects({ enabled: open });
const hosts = useHosts();
const workspacesArriving = useWorkspacesArriving(open, hosts);
const showHost = hosts.length > 1;
function useBuiltInRows(open: boolean): {
workspaces: CommandCenterWorkspaceResult[];
agents: CommandCenterAgentResult[];
} {
const { t } = useTranslation();
const previousRows = useRef(
new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>(),
);
const { agents } = useAggregatedAgents({ demand: open });
const { projects } = useProjects({ enabled: open });
const showHost = useHosts().length > 1;
const iconTargets = useMemo(
() =>
open
@@ -209,26 +163,24 @@ function useWorkspaceRows(
);
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
const active = useMemo(() => {
return useMemo(() => {
if (!open) {
previousRows.current.clear();
return [];
return { workspaces: [], agents: [] };
}
// Workspace IDs identify immutable navigation targets. Reuse their callbacks and unchanged
// Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged
// row data so a directory update does not invalidate every mounted ResultRow.
const nextRows = new Map<string, CommandCenterWorkspaceResult>();
const preserve = (row: CommandCenterWorkspaceResult): CommandCenterWorkspaceResult => {
const nextRows = new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>();
const preserve = <T extends CommandCenterAgentResult | CommandCenterWorkspaceResult>(
row: T,
): T => {
const previous = previousRows.current.get(row.id);
if (previous) {
row.run = previous.run;
row.openAgent = previous.openAgent;
}
const stable = previous && equal(previous, row) ? previous : row;
if (previous) row.run = previous.run;
const stable = previous && equal(previous, row) ? (previous as T) : row;
nextRows.set(row.id, stable);
return stable;
};
const agentsByWorkspace = groupWorkspaceAgents(agents);
const rows: CommandCenterWorkspaceResult[] = [];
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
for (const project of projects) {
const workspaceProject = {
viewKey: project.viewKey,
@@ -238,11 +190,10 @@ function useWorkspaceRows(
for (const host of project.hosts) {
for (const workspace of host.workspaces) {
if (workspace.archivingAt) continue;
const key = `${host.serverId}:${workspace.id}`;
rows.push(
allWorkspaces.push(
preserve({
kind: "workspace",
id: `workspace:${key}`,
id: `workspace:${host.serverId}:${workspace.id}`,
title: workspace.title ?? workspace.name,
subtitle: joinSubtitleParts([
showHost ? host.serverName : null,
@@ -254,93 +205,66 @@ function useWorkspaceRows(
project: workspaceProject,
changeRequestNumber: workspace.changeRequestNumber,
activityAt: workspace.statusEnteredAt,
agents: agentsByWorkspace.get(key) ?? NO_WORKSPACE_AGENTS,
matchedAgent: null,
archived: false,
background: workspace.background,
run: createWorkspaceResultAction(host.serverId, workspace.id),
openAgent: createAgentResultAction(host.serverId),
}),
);
}
}
}
previousRows.current = nextRows;
return rows;
}, [agents, iconDataByProjectViewKey, open, projects, showHost]);
const archived = useMemo(() => {
const activeIds = new Set(active.map((row) => row.id));
const hostLabels = new Map(hosts.map((host) => [host.serverId, host.label]));
return archivedWorkspaces.flatMap<CommandCenterWorkspaceResult>((workspace) => {
const id = `workspace:${workspace.key}`;
// The host's history can trail its workspace list; the active row wins.
if (activeIds.has(id)) return [];
const project = workspace.projectKey
? getProjectSummaryForHostProject(projects, workspace.serverId, workspace.projectKey)
const workspaceTitleByKey = new Map(
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
);
const agentRows = agents.map<CommandCenterAgentResult>((agent) => {
const workspaceTitle = agent.workspaceId
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
: undefined;
const projectName = project?.projectName ?? workspace.projectName ?? workspace.name;
const openAgent = createArchivedAgentAction(workspace.serverId, workspace.workspaceId);
const latestAgentId = workspace.agents[0].id;
return [
{
kind: "workspace",
id,
title: workspace.name,
subtitle: joinSubtitleParts([
showHost ? (hostLabels.get(workspace.serverId) ?? workspace.serverId) : null,
projectName,
workspace.branch,
]),
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
project: {
viewKey: project?.viewKey ?? `archived:${workspace.key}`,
name: projectName,
iconDataUri: project ? (iconDataByProjectViewKey.get(project.viewKey) ?? null) : null,
},
changeRequestNumber: null,
activityAt: workspace.archivedAt,
agents: titledAgents(workspace.agents),
matchedAgent: null,
archived: true,
background: false,
run: () => openAgent(latestAgentId),
openAgent,
},
];
return preserve({
kind: "agent",
id: `agent:${agent.serverId}:${agent.id}`,
agent,
title: resolveAgentLabel(agent.title) ?? t("workspace.tabs.loading"),
subtitle: joinSubtitleParts([
showHost ? agent.serverLabel : null,
workspaceTitle ?? shortenPath(agent.cwd),
]),
activityAt: agent.lastActivityAt,
run: createAgentResultAction(agent.serverId, agent.id),
});
});
}, [active, archivedWorkspaces, hosts, iconDataByProjectViewKey, projects, showHost]);
return {
active,
archived,
// Opening a workspace route loads that workspace before the rest of the directory, so rows
// are no sign that the list is complete.
loading: agentsLoading || projectsLoading || workspacesArriving,
};
previousRows.current = nextRows;
return { workspaces: allWorkspaces, agents: agentRows };
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
}
/**
* An online host's workspaces have not arrived yet. Its agent directory reports ready before
* the workspace snapshot lands, so until then the workspace rows are incomplete.
*/
function useWorkspacesArriving(open: boolean, hosts: readonly { serverId: string }[]): boolean {
const serverIds = useMemo(() => (open ? hosts.map((host) => host.serverId) : []), [hosts, open]);
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds);
return serverIds.some(
(serverId) =>
connectionStatuses.get(serverId) === "online" && !hydratedServerIds.includes(serverId),
);
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
const { t } = useTranslation();
const rows = useBuiltInRows(open);
return useMemo(() => {
if (!open) return [];
return [
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
title: t("shell.commandCenter.workspaces"),
results: filterAndRankWorkspaces(rows.workspaces, query),
},
{
id: "agents",
band: PINNED_SECTION_BAND,
rank: 3,
title: t("shell.commandCenter.agents"),
results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields),
},
];
}, [open, query, rows, t]);
}
interface CommandCenterState {
open: boolean;
/** The tabs on offer, in the order Tab walks them. */
tabs: readonly CommandCenterTab[];
tab: CommandCenterTab;
selectTab(tab: CommandCenterTab): void;
scope: CommandCenterScope;
clearScope(): void;
query: string;
setQuery(query: string): void;
activeId: string | null;
@@ -351,7 +275,7 @@ interface CommandCenterState {
fileSearchError: string | null;
close(): void;
select(result: CommandCenterResult): void;
key(event: { key: string; shiftKey?: boolean }): boolean;
key(key: string): boolean;
}
function useCommandCenterState(
@@ -361,95 +285,62 @@ function useCommandCenterState(
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
const requestedTab = useKeyboardShortcutsStore((state) => state.commandCenterTab);
const scope = useKeyboardShortcutsStore((state) => state.commandCenterScope);
const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const setTab = useKeyboardShortcutsStore((state) => state.setCommandCenterTab);
const setScope = useKeyboardShortcutsStore((state) => state.setCommandCenterScope);
const snapshot = useCommandCenterContributions();
const inputRef = useRef<EditingTextInputHandle>(null);
const previousOpenRef = useRef(open);
const { query, activeId } = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
const activeWorkspace = useActiveWorkspaceSelection();
const hasWorkspace = activeWorkspace !== null;
const tabs = useMemo(() => availableCommandCenterTabs({ hasWorkspace }), [hasWorkspace]);
const tab = resolveCommandCenterTab(requestedTab, tabs);
const showBackground = useSidebarViewStore((state) => state.showBackground);
const listsWorkspaces = tab === "all" || tab === "workspaces";
const archivedSearch = useArchivedWorkspaceSearch(query, {
enabled: open && listsWorkspaces,
});
const workspaceRows = useWorkspaceRows(open, archivedSearch.workspaces);
const [rankWorkspaces] = useState(createWorkspaceRanker);
const currentWorkspaceId = activeWorkspace
? `workspace:${activeWorkspace.serverId}:${activeWorkspace.workspaceId}`
: null;
const builtInSections = useBuiltInSections(open, query);
const {
entries: fileSearchEntries,
loading: fileSearchLoading,
error: fileSearchError,
openFile,
} = useWorkspaceFileSearch({
enabled:
open &&
tabs.includes("files") &&
(tab === "files" || (tab === "all" && Boolean(query.trim()))),
enabled: open && (scope === "files" || Boolean(query.trim())),
query,
});
const files = useMemo(
() =>
fileSearchEntries.map<CommandCenterFileResult>((entry) => ({
kind: "file",
id: `file:${entry.path}`,
filePath: entry.path,
title: entry.name,
subtitle: entry.directory,
run: () => openFile(entry.path),
})),
[fileSearchEntries, openFile],
);
const sections = useMemo(
() =>
open
? buildCommandCenterSections({
tab,
query,
rankWorkspaces,
workspaces: workspaceRows.active,
archivedWorkspaces: workspaceRows.archived,
archivedMatchedByHost: archivedSearch.matchedByHost,
showBackground,
currentWorkspaceId,
contributions: snapshot.contributions,
files,
titles: {
recentWorkspaces: t("shell.commandCenter.recentWorkspaces"),
workspaces: t("shell.commandCenter.workspaces"),
files: t("shell.commandCenter.files"),
},
})
: [],
[
archivedSearch.matchedByHost,
currentWorkspaceId,
files,
open,
query,
rankWorkspaces,
showBackground,
snapshot.contributions,
t,
tab,
workspaceRows,
],
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
const results = fileSearchEntries.map<CommandCenterFileResult>((entry) => ({
kind: "file",
id: `file:${entry.path}`,
filePath: entry.path,
title: entry.name,
subtitle: entry.directory,
run: () => openFile(entry.path),
}));
// File rows are matched by the daemon, so they carry no client-side score and cannot be
// relevance-compared against the rows that do. Pinning them keeps the section comparator
// total: band is decided before any score is read.
return [
{
id: "files",
band: PINNED_SECTION_BAND,
rank: 4,
title: t("shell.commandCenter.files"),
results,
},
];
}, [fileSearchEntries, openFile, t]);
const contributionSections = useMemo(
() => buildContributionSections(snapshot.contributions, query),
[query, snapshot.contributions],
);
const projection = useMemo(
() => projectCommandCenterRows(sections, listMetrics),
[listMetrics, sections],
() =>
projectCommandCenterRows(
scope === "files"
? fileSections
: [...contributionSections, ...fileSections, ...builtInSections],
listMetrics,
),
[builtInSections, contributionSections, fileSections, listMetrics, scope],
);
const loading =
fileSearchLoading || (listsWorkspaces && (archivedSearch.pending || workspaceRows.loading));
useLayoutEffect(() => {
interaction.update({ open, tab, query, projection, loading });
}, [interaction, open, tab, query, projection, loading]);
interaction.update({ open, scope, query, projection, loading: fileSearchLoading });
}, [interaction, open, scope, query, projection, fileSearchLoading]);
const close = useCallback(() => setOpen(false), [setOpen]);
const select = useCallback(
@@ -459,29 +350,16 @@ function useCommandCenterState(
},
[setOpen],
);
const selectTab = useCallback(
(next: CommandCenterTab) => {
setTab(next);
// A tab click must not take typing away from the search field.
inputRef.current?.focus();
},
[setTab],
);
const key = useCallback(
(event: { key: string; shiftKey?: boolean }): boolean => {
(pressed: string): boolean => {
if (!open) return false;
const pressed = event.key;
const results = projection.selectableResults;
if (pressed === "Escape") {
close();
return true;
}
if (pressed === "Tab") {
setTab(rotateCommandCenterTab(tab, event.shiftKey ? "previous" : "next", tabs));
return true;
}
if (pressed === "Backspace" && !query.trim() && tab !== "all") {
setTab("all");
if (pressed === "Backspace" && !query.trim() && scope) {
setScope(null);
return true;
}
if (pressed === "Enter") {
@@ -499,10 +377,9 @@ function useCommandCenterState(
projection.selectableResults,
query,
activeId,
tab,
tabs,
scope,
select,
setTab,
setScope,
interaction,
],
);
@@ -528,9 +405,8 @@ function useCommandCenterState(
return {
open,
tabs,
tab,
selectTab,
scope,
clearScope: () => setScope(null),
query,
setQuery: interaction.setQuery,
activeId,
@@ -660,38 +536,55 @@ function SubtitleWithTimestamp({
);
}
/**
* The third line of a workspace that matched only through one of its agents: which agent, so the
* user can tell why the row is here. It starts on the title's rail.
*/
function MatchedAgentLine({
agent,
serverId,
const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function AgentResultContent({
result,
query,
backdrop,
}: {
agent: CommandCenterWorkspaceAgent;
serverId: string;
result: CommandCenterAgentResult;
query: string;
backdrop: SurfaceBackdrop;
}) {
const ProviderIcon = useProviderIcon(agent.provider, serverId);
const Icon = useMemo(() => withUnistyles(ProviderIcon, providerIconMapping), [ProviderIcon]);
const agent = result.agent;
const descriptor = useAgentDescriptor(
{ kind: "agent", agentId: agent.id },
{ serverId: agent.serverId },
);
const Icon = useMemo(
() => withUnistyles(descriptor.icon, providerIconMapping),
[descriptor.icon],
);
return (
<View style={styles.agentLine} testID="command-center-workspace-agent">
<View style={styles.agentLineIcon}>
<Icon size={12} />
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<StatusIcon statusBucket={descriptor.statusBucket} size={16} backdrop={backdrop}>
<View accessibilityRole="image" accessibilityLabel={descriptor.subtitle}>
<Icon size={16} />
</View>
</StatusIcon>
</View>
<View style={styles.textContent}>
<QueryHighlightedText
query={query}
text={result.title}
style={styles.title}
numberOfLines={1}
/>
<SubtitleWithTimestamp
query={query}
subtitle={result.subtitle}
activityAt={result.activityAt}
testID="command-center-agent-subtitle"
/>
</View>
</View>
<QueryHighlightedText
query={query}
text={agent.title}
style={[styles.subtitle, styles.subtitleText]}
numberOfLines={1}
/>
</View>
);
}
const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function WorkspaceResultIcon({
result,
backdrop,
@@ -702,27 +595,24 @@ function WorkspaceResultIcon({
const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId);
return (
<StatusIcon statusBucket={statusBucket} size={16} backdrop={backdrop}>
<DecorativeProjectIcon project={result.project} />
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
<WorkspaceProjectIcon project={result.project} />
</View>
</StatusIcon>
);
}
/**
* The same project icon the sidebar's workspace rows lead with: its image, or a lettered square.
* Hidden from assistive tech so the fallback's initial does not prefix the row's name.
*/
function DecorativeProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
return (
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
<ProjectIconView
iconDataUri={project.iconDataUri}
initial={initial}
projectViewKey={project.viewKey}
size={16}
textStyle={styles.projectIconFallbackText}
/>
</View>
<ProjectIconView
iconDataUri={project.iconDataUri}
initial={initial}
projectViewKey={project.viewKey}
size={16}
textStyle={styles.projectIconFallbackText}
/>
);
}
@@ -760,18 +650,17 @@ function ResultContent({
</View>
);
}
if (result.kind === "agent") {
return <AgentResultContent result={result} query={query} backdrop={backdrop} />;
}
if (result.kind === "workspace") {
const key = result.id.slice("workspace:".length);
return (
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
<View style={styles.rowMain}>
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
<View style={styles.iconSlot}>
{/* An archived workspace runs nothing, so it has no status to show. */}
{result.archived ? (
<DecorativeProjectIcon project={result.project} />
) : (
<WorkspaceResultIcon result={result} backdrop={backdrop} />
)}
<WorkspaceResultIcon result={result} backdrop={backdrop} />
</View>
<View style={styles.textContent}>
<QueryHighlightedText
@@ -786,16 +675,8 @@ function ResultContent({
activityAt={result.activityAt}
testID="command-center-workspace-subtitle"
/>
{result.matchedAgent ? (
<MatchedAgentLine
agent={result.matchedAgent}
serverId={result.serverId}
query={query}
/>
) : null}
</View>
</View>
{result.archived ? <ArchivedBadge /> : null}
</View>
);
}
@@ -880,24 +761,9 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
}
// Escape arrives without asking; it is registered for `agent.interrupt` anyway.
// Backspace stays off deliberately — on web it returns a tab to All when the field is empty,
// Backspace stays off deliberately — the palette pops its scope with it on web,
// but a registered command would stop the search field deleting text.
const TAB_PLACEHOLDER_KEYS = {
all: "shell.commandCenter.placeholder",
workspaces: "shell.commandCenter.workspacePlaceholder",
files: "shell.commandCenter.filePlaceholder",
actions: "shell.commandCenter.actionPlaceholder",
} as const satisfies Record<CommandCenterTab, string>;
/** The search field names what the tab searches; All names only the kinds on offer. */
function placeholderKey(tab: CommandCenterTab, tabs: readonly CommandCenterTab[]) {
if (tab === "all" && !tabs.includes("files")) {
return "shell.commandCenter.placeholderWithoutFiles";
}
return TAB_PLACEHOLDER_KEYS[tab];
}
export function CommandCenter() {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
@@ -1009,42 +875,22 @@ export function CommandCenter() {
: {}),
scrollEventThrottle: 16,
};
const submit = useCallback(() => state.key({ key: "Enter" }), [state]);
const submit = useCallback(() => state.key("Enter"), [state]);
const handleOverlayKeyDown = useCallback(
(event: KeyboardInputEvent) => {
if (!state.key(event)) return false;
if (!state.key(event.key)) return false;
event.preventDefault();
return true;
},
[state],
);
const setOverlayScope = useOverlayRegistration({
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown", "Tab"],
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
active: state.open,
layer: modalLayer,
onKeyDown: handleOverlayKeyDown,
});
const tabOptions = useMemo<SegmentedControlOption<CommandCenterTab>[]>(
() =>
state.tabs.map((tab) => ({
value: tab,
label: t(`shell.commandCenter.tabs.${tab}`),
testID: `command-center-tab-${tab}`,
})),
[state.tabs, t],
);
const tabs = (
<SegmentedControl
options={tabOptions}
value={state.tab}
onValueChange={state.selectTab}
size="sm"
testID="command-center-tabs"
/>
);
const placeholder = t(placeholderKey(state.tab, state.tabs));
if (showBottomSheet) {
return (
<IsolatedBottomSheetModal
@@ -1064,28 +910,32 @@ export function CommandCenter() {
accessible={false}
>
<OverlayLayerProvider layer={modalLayer}>
<View style={styles.header} testID="command-center-header">
<View style={styles.searchRow}>
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
initialValue={state.query}
variant="bottom-sheet"
onChangeText={state.setQuery}
onSubmitEditing={submit}
placeholder={placeholder}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
initialValue={state.query}
variant="bottom-sheet"
onChangeText={state.setQuery}
onSubmitEditing={submit}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
<View style={styles.tabBar}>{tabs}</View>
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</OverlayLayerProvider>
@@ -1099,26 +949,30 @@ export function CommandCenter() {
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View ref={setOverlayScope} testID="command-center-panel" style={styles.panel}>
<View style={styles.header} testID="command-center-header">
<View style={styles.searchRow}>
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
initialValue={state.query}
onChangeText={state.setQuery}
placeholder={placeholder}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
initialValue={state.query}
onChangeText={state.setQuery}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
<View style={styles.tabBar}>{tabs}</View>
{fileSearchError}
<FlatList ref={listRef} {...commonListProps} />
</View>
@@ -1144,6 +998,25 @@ function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; labe
);
}
// The chip sits at the input's own height and type size so it reads as the scope of the field
// rather than a badge dropped into the query. Its 28px matches the input's line box, so showing or
// dropping the scope cannot resize the header.
function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onRemove}
style={styles.scopeChip}
testID="command-center-files-scope"
>
<ThemedFolder size={14} strokeWidth={2.2} />
<Text style={styles.scopeChipLabel}>{label}</Text>
<ThemedX size={12} strokeWidth={2.2} />
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
@@ -1170,12 +1043,11 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
// Below the divider, above the list. The first section label brings its own top gap, so the
// pills sit with equal space above and below. A fixed bar, so switching tabs never moves the
// field or the list.
tabBar: {
bottomSheetHeader: {
paddingHorizontal: theme.spacing[4],
paddingTop: COMMAND_CENTER_SECTION_GAP,
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
input: {
fontSize: theme.fontSize.base,
@@ -1186,6 +1058,25 @@ const styles = StyleSheet.create((theme) => ({
growingInput: { flex: 1, minWidth: 0 },
searchRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
fileSearchStatus: { width: 14, height: 14, alignItems: "center", justifyContent: "center" },
scopeChip: {
// Stretching to the row rather than setting a height keeps the chip exactly as tall as the
// input's line box, so showing or dropping the scope can never resize the header.
alignSelf: "stretch",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
marginRight: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface2,
},
scopeChipLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: "500",
},
results: { flex: 1 },
// Sections and rows take their height from list-metrics; these styles only place content in it.
section: { justifyContent: "flex-end" },
@@ -1238,14 +1129,6 @@ const styles = StyleSheet.create((theme) => ({
},
subtitleLine: { flexDirection: "row", minWidth: 0 },
subtitleText: { flexShrink: 1, minWidth: 0 },
agentLine: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
minWidth: 0,
height: COMMAND_CENTER_LINE_HEIGHT.agent,
},
agentLineIcon: { width: 12, alignItems: "center", justifyContent: "center" },
iconSlot: {
width: 16,
height: COMMAND_CENTER_LINE_HEIGHT.singleLine,
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { createCommandCenterInteraction } from "./interaction";
import { projectCommandCenterRows, type CommandCenterFileResult } from "./results";
import type { CommandCenterTab } from "./tabs";
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
function results(ids = ["a", "b", "c", "d"]) {
return projectCommandCenterRows(
@@ -21,17 +21,17 @@ function results(ids = ["a", "b", "c", "d"]) {
})),
},
],
{ singleLineRow: 40, twoLineRow: 60, threeLineRow: 80, sectionTitle: 20, sectionDivider: 10 },
{ singleLineRow: 40, twoLineRow: 60, sectionTitle: 20, sectionDivider: 10 },
);
}
function setup(options: { tab?: CommandCenterTab; measured?: boolean } = {}) {
function setup(options: { scope?: CommandCenterScope; measured?: boolean } = {}) {
const offsets: number[] = [];
const model = createCommandCenterInteraction((offset) => offsets.push(offset));
const publish = (ids = ["a", "b", "c", "d"], loading = false) =>
model.update({
open: true,
tab: options.tab ?? "all",
scope: options.scope ?? null,
query: model.getSnapshot().query,
projection: results(ids),
loading,
@@ -53,45 +53,6 @@ describe("Command Center interaction", () => {
expect(offsets).toEqual([]);
});
it("follows the first result while results load, so Enter does not run what rendered first", () => {
const { model, publish } = setup();
model.setQuery("");
// Recent workspaces lead All and arrive after the actions below them.
publish(["new-workspace", "settings"], true);
expect(model.getSnapshot().activeId).toBe("new-workspace");
publish(["recent", "new-workspace", "settings"]);
expect(model.getSnapshot().activeId).toBe("recent");
});
it("keeps the selected row when settled results reorder", () => {
const { model, publish } = setup();
expect(model.getSnapshot().activeId).toBe("a");
// Another workspace's agent changed status, so it moves to the top of Recent workspaces.
publish(["b", "a", "c", "d"]);
expect(model.getSnapshot().activeId).toBe("a");
});
it("keeps the selected row when results land above it after the query settled", () => {
const { model, publish } = setup();
model.setQuery("invoice");
// Files arrive while archived workspaces are still being searched.
publish(["file-1", "file-2"], true);
expect(model.getSnapshot().activeId).toBe("file-1");
publish(["archived", "file-1", "file-2"]);
expect(model.getSnapshot().activeId).toBe("archived");
publish(["archived-2", "archived", "file-1", "file-2"]);
expect(model.getSnapshot().activeId).toBe("archived");
});
it("keeps the user's pick while results are still loading", () => {
const { model, publish } = setup();
model.setQuery("q");
publish(["a", "b"], true);
model.move("next");
publish(["z", "a", "b"], true);
expect(model.getSnapshot().activeId).toBe("b");
});
it("reveals only clipped keyboard selections and wraps around", () => {
const { model, offsets } = setup();
expect(model.move("next")).toBe(true);
@@ -159,7 +120,7 @@ describe("Command Center interaction", () => {
});
it("waits for scoped search results instead of revealing the previous query's rows", () => {
const { model, offsets, publish } = setup({ tab: "files" });
const { model, offsets, publish } = setup({ scope: "files" });
model.scrolled(100);
model.setQuery("new");
publish(["a", "b"], true);
@@ -171,7 +132,7 @@ describe("Command Center interaction", () => {
});
it("manual interaction cancels a reveal awaiting search results", () => {
const { model, offsets, publish } = setup({ tab: "files" });
const { model, offsets, publish } = setup({ scope: "files" });
model.setQuery("new");
publish([], true);
model.beginManualScroll();
@@ -192,29 +153,13 @@ describe("Command Center interaction", () => {
expect(offsets).toEqual([]);
});
it("tab changes select the first result and return the list to the top", () => {
it("scope changes reset selection and request a reveal", () => {
const { model, offsets } = setup();
model.move("next");
model.move("next");
model.move("next");
model.scrolled(100);
model.update({ open: true, tab: "files", query: "", projection: results(), loading: false });
model.update({ open: true, scope: "files", query: "", projection: results(), loading: false });
expect(model.getSnapshot().activeId).toBe("a");
expect(offsets.at(-1)).toBe(0);
});
it("tab changes keep the query", () => {
const { model, publish } = setup();
model.setQuery("b");
publish(["b"]);
model.update({
open: true,
tab: "workspaces",
query: "b",
projection: results(["b"]),
loading: false,
});
expect(model.getSnapshot()).toEqual({ query: "b", activeId: "b" });
expect(offsets).toEqual([20, 20]);
});
it("close discards the old query, metrics, and deferred reveal", () => {
@@ -222,7 +167,7 @@ describe("Command Center interaction", () => {
model.setQuery("old");
publish();
model.move("previous");
model.update({ open: false, tab: "all", query: "old", projection: results(), loading: false });
model.update({ open: false, scope: null, query: "old", projection: results(), loading: false });
model.layout(80);
expect(model.getSnapshot()).toEqual({ query: "", activeId: null });
expect(offsets).toEqual([]);
+11 -30
View File
@@ -1,9 +1,9 @@
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
import {
moveActiveResultId,
preserveActiveResultId,
type CommandCenterListProjection,
} from "./results";
import type { CommandCenterTab } from "./tabs";
interface Snapshot {
query: string;
@@ -12,28 +12,21 @@ interface Snapshot {
interface ResultsInput {
open: boolean;
tab: CommandCenterTab;
scope: CommandCenterScope;
query: string;
projection: CommandCenterListProjection;
/** A source of this query's and tab's results is still loading. */
loading: boolean;
}
/**
* While a query's or tab's results are still loading, the selection follows the first result,
* so Enter right after opening runs what ends up on top rather than whatever rendered first.
* Once they have all loaded, or the user moves the selection, it stays on its row by ID through
* any later reordering. Permission to move the viewport lasts for one user command.
*/
/** Selection persists; permission to move the viewport lasts for one user command. */
export function createCommandCenterInteraction(scrollToOffset: (offset: number) => void) {
let snapshot: Snapshot = { query: "", activeId: null };
const listeners = new Set<() => void>();
let open = false;
let tab: CommandCenterTab = "all";
let scope: CommandCenterScope = null;
let projection: CommandCenterListProjection | null = null;
let loading = false;
let revealPending = false;
let followingFirst = true;
let offset = 0;
let visibleLength = 0;
@@ -45,8 +38,8 @@ export function createCommandCenterInteraction(scrollToOffset: (offset: number)
function reveal() {
if (!open || !revealPending || !projection || visibleLength <= 0) return;
// The Files tab keeps its previous entries while the next query is loading.
if (tab === "files" && loading) return;
// Scoped file search keeps its previous entries while the next query is loading.
if (scope === "files" && loading) return;
if (!snapshot.activeId) {
if (!loading) revealPending = false;
return;
@@ -74,10 +67,9 @@ export function createCommandCenterInteraction(scrollToOffset: (offset: number)
};
},
update(input: ResultsInput) {
const tabChanged = open && input.open && tab !== input.tab;
const resetSelection = input.open && (!open || tabChanged);
const resetSelection = input.open && (!open || scope !== input.scope);
open = input.open;
tab = input.tab;
scope = input.scope;
if (!open) {
projection = null;
revealPending = false;
@@ -88,44 +80,33 @@ export function createCommandCenterInteraction(scrollToOffset: (offset: number)
}
if (resetSelection) {
revealPending = true;
followingFirst = true;
publish({ ...snapshot, activeId: null });
}
// A tab is a different list: it starts at its top, section label included.
if (tabChanged && offset !== 0) {
offset = 0;
scrollToOffset(0);
}
// A query command invalidates the old projection until React supplies its new results.
if (input.query !== snapshot.query) return;
projection = input.projection;
loading = input.loading;
if (tab !== "files" || !loading) {
if (scope !== "files" || !loading) {
publish({
...snapshot,
activeId: followingFirst
? (projection.selectableResults[0]?.id ?? null)
: preserveActiveResultId(snapshot.activeId, projection.selectableResults),
activeId: preserveActiveResultId(snapshot.activeId, projection.selectableResults),
});
if (!loading) followingFirst = false;
}
reveal();
},
setQuery(query: string) {
projection = null;
revealPending = true;
followingFirst = true;
publish({ query, activeId: null });
},
move(direction: "next" | "previous"): boolean {
if (
!open ||
!projection ||
(tab === "files" && loading) ||
(scope === "files" && loading) ||
projection.selectableResults.length === 0
)
return false;
followingFirst = false;
publish({
...snapshot,
activeId: moveActiveResultId(snapshot.activeId, projection.selectableResults, direction),
@@ -16,8 +16,6 @@ export const COMMAND_CENTER_LINE_HEIGHT = {
singleLine: 20,
title: 18,
subtitle: 16,
/** The matched-agent line under a workspace's subtitle. */
agent: 16,
sectionLabel: 16,
} as const;
@@ -28,8 +26,6 @@ const SECTION_DIVIDER_THICKNESS = 1;
export interface CommandCenterListMetrics {
singleLineRow: number;
twoLineRow: number;
/** A workspace row that also names the agent the query matched. */
threeLineRow: number;
/** A section label and the gaps around it. */
sectionTitle: number;
/** The rule between two sections and the gaps around it. */
@@ -41,7 +37,6 @@ function listMetrics(rowInset: number): CommandCenterListMetrics {
return {
singleLineRow: rowInset + line.singleLine + rowInset,
twoLineRow: rowInset + line.title + line.subtitle + rowInset,
threeLineRow: rowInset + line.title + line.subtitle + line.agent + rowInset,
sectionTitle: COMMAND_CENTER_SECTION_GAP + line.sectionLabel + COMMAND_CENTER_SECTION_GAP,
sectionDivider:
COMMAND_CENTER_SECTION_GAP + SECTION_DIVIDER_THICKNESS + COMMAND_CENTER_SECTION_GAP,
+45 -47
View File
@@ -3,6 +3,7 @@ import type { CommandCenterContribution } from "./contributions";
import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics";
import {
buildContributionSections,
filterAndRankBuiltInResults,
joinSubtitleParts,
moveActiveResultId,
PINNED_SECTION_BAND,
@@ -10,7 +11,6 @@ import {
projectCommandCenterRows,
type CommandCenterWorkspaceResult,
} from "./results";
import { createWorkspaceRanker } from "./workspace-search";
function contribution(input: {
id: string;
@@ -118,12 +118,7 @@ function workspace(
project: { viewKey: "project", name: "project", iconDataUri: null },
changeRequestNumber: null,
activityAt,
agents: [],
matchedAgent: null,
archived: false,
background: false,
run: () => undefined,
openAgent: () => undefined,
};
}
@@ -189,40 +184,6 @@ describe("Command Center result projection", () => {
expect(sectionResultIds(buildContributionSections(contributions, "home"))).toEqual(["home"]);
});
it("lists query-only actions without a query when asked to browse them", () => {
const contributions = [
contribution({ id: "settings", group: "actions", groupRank: 0 }),
contribution({ id: "opus", group: "models", groupRank: 1, visibility: "query" }),
];
expect(
sectionResultIds(buildContributionSections(contributions, "", { includeQueryOnly: true })),
).toEqual(["settings", "opus"]);
});
it("gives a workspace row matched through an agent room for its agent line", () => {
const matched = {
...workspace("workspace:matched"),
matchedAgent: { id: "agent", title: "Add receipt emails", provider: "mock" },
};
const projection = projectCommandCenterRows(
[
{
id: "workspaces",
band: PINNED_SECTION_BAND,
rank: 2,
results: [workspace("workspace:plain"), matched],
},
],
metrics,
);
expect(projection.rows.map((row) => row.height)).toEqual([
metrics.twoLineRow,
metrics.threeLineRow,
]);
expect(metrics.threeLineRow).toBeGreaterThan(metrics.twoLineRow);
});
it("preserves active selection by id and falls back to the first result", () => {
const first = workspace("workspace:first");
const second = workspace("workspace:second");
@@ -525,9 +486,25 @@ describe("Command Center query tokenization", () => {
});
});
describe("workspace ranking", () => {
const rank = (rows: readonly CommandCenterWorkspaceResult[], query: string) =>
createWorkspaceRanker()(rows, query).map((row) => row.id);
describe("filterAndRankBuiltInResults", () => {
const visibleOnly = (row: CommandCenterWorkspaceResult) => ({
visible: [row.title, row.subtitle],
hidden: [],
});
it("shows the five most recent rows for an empty query", () => {
const rows = Array.from({ length: 8 }, (_, index) =>
workspace(`w${index}`, `w${index}`, "host", minutesAgo(8 - index)),
);
expect(filterAndRankBuiltInResults(rows, "", visibleOnly).map((row) => row.id)).toEqual([
"w7",
"w6",
"w5",
"w4",
"w3",
]);
});
it("puts the best match first even when it sorts last alphabetically", () => {
const rows = [
@@ -537,7 +514,22 @@ describe("workspace ranking", () => {
];
// Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design.
expect(rank(rows, "lab des")).toEqual(["c"]);
expect(filterAndRankBuiltInResults(rows, "lab des", visibleOnly).map((row) => row.id)).toEqual([
"c",
]);
});
it("ranks a title match above a match that only the hidden field reached", () => {
const rows = [workspace("hidden-hit", "Unrelated"), workspace("title-hit", "Deploy")];
const fields = (row: CommandCenterWorkspaceResult) =>
row.id === "hidden-hit"
? { visible: [row.title], hidden: ["deploy"] }
: { visible: [row.title], hidden: [] };
expect(filterAndRankBuiltInResults(rows, "deploy", fields).map((row) => row.id)).toEqual([
"title-hit",
"hidden-hit",
]);
});
it("orders rows in the same match tier by recency, not by where the word sits", () => {
@@ -547,7 +539,10 @@ describe("workspace ranking", () => {
workspace("newer", "design the cli interface to expose usage data", "host", minutesAgo(5)),
];
expect(rank(rows, "usage")).toEqual(["newer", "older"]);
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
"newer",
"older",
]);
});
it("keeps a better match tier above a more recent weaker match", () => {
@@ -556,13 +551,16 @@ describe("workspace ranking", () => {
workspace("whole-word", "Usage hover card", "host", minutesAgo(600)),
];
expect(rank(rows, "usage")).toEqual(["whole-word", "subsequence"]);
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
"whole-word",
"subsequence",
]);
});
it("drops a row when one token matches nothing", () => {
const rows = [workspace("a", "Label as Design")];
expect(rank(rows, "lab zzz")).toEqual([]);
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly)).toEqual([]);
});
});
+51 -48
View File
@@ -1,15 +1,12 @@
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import {
compareMatchScores,
type MatchClass,
type MatchScore,
scoreTextFields,
textFieldsMatchClass,
} from "@getpaseo/protocol/search/text-match";
import type { CommandCenterContribution } from "./contributions";
import type { CommandCenterListMetrics } from "./list-metrics";
/** Sections pinned above every contribution section. */
export const LEADING_SECTION_BAND = -1;
/** Sections that reorder by how well they match the query. */
export const CONTRIBUTION_SECTION_BAND = 0;
/** Sections pinned below every contribution section regardless of match quality. */
@@ -53,13 +50,6 @@ export interface CommandCenterWorkspaceProject {
iconDataUri: string | null;
}
/** An agent a workspace can be found by. Agents are never results of their own. */
export interface CommandCenterWorkspaceAgent {
id: string;
title: string;
provider: string;
}
export interface CommandCenterWorkspaceResult {
kind: "workspace";
id: string;
@@ -70,19 +60,18 @@ export interface CommandCenterWorkspaceResult {
project: CommandCenterWorkspaceProject;
/** PR/MR number backing this workspace, so a number query can match it exactly. */
changeRequestNumber: number | null;
/** Last activity, or when it was archived for an archived workspace. */
activityAt: Date | null;
/** The workspace's agents, most recent first. Part of what the workspace matches on. */
agents: readonly CommandCenterWorkspaceAgent[];
/** The agent the query matched better than the workspace's own text. Drawn as a third line. */
matchedAgent: CommandCenterWorkspaceAgent | null;
archived: boolean;
/** Started by automation rather than the user. Found by a query, never listed as recent. */
background: boolean;
/** Opens the workspace and lets its default tab selection apply. */
run(): void;
/** Opens the workspace on this agent's tab. */
openAgent(agentId: string): void;
}
export interface CommandCenterAgentResult {
kind: "agent";
id: string;
agent: AggregatedAgent;
title: string;
subtitle: string;
activityAt: Date | null;
run(): void;
}
export interface CommandCenterFileResult {
@@ -105,12 +94,13 @@ export interface CommandCenterContributionResult {
export type CommandCenterResult =
| CommandCenterWorkspaceResult
| CommandCenterAgentResult
| CommandCenterFileResult
| CommandCenterContributionResult;
export interface CommandCenterResultSection {
id: string;
/** LEADING, CONTRIBUTION or PINNED_SECTION_BAND. Compared before anything else. */
/** CONTRIBUTION_SECTION_BAND or PINNED_SECTION_BAND. Compared before anything else. */
band: number;
rank: number;
title?: string;
@@ -172,14 +162,6 @@ export function scoreSearchFields(
return match ? { fieldRank: KEYWORD_FIELD_RANK, match } : null;
}
/** How closely the query matched the fields; see `MatchClass`. Null when it does not match. */
export function searchFieldsMatchClass(
query: string,
fields: CommandCenterSearchFields,
): MatchClass | null {
return textFieldsMatchClass(query, [...fields.visible, ...fields.hidden]);
}
/** Join non-empty subtitle parts with " · ", dropping null/undefined/empty. */
export function joinSubtitleParts(parts: readonly (string | null | undefined)[]): string {
return parts.filter((part): part is string => Boolean(part)).join(" · ");
@@ -199,18 +181,12 @@ function contributionSearchFields(
return { visible, hidden: contribution.keywords };
}
/** How many lines of text the row draws, which decides its height. */
function resultLineCount(result: CommandCenterResult): 1 | 2 | 3 {
if (result.kind === "workspace") return result.matchedAgent ? 3 : 2;
if (result.kind === "file") return 1;
/** Whether the row draws a second, subtitle line under its title. */
function hasSubtitleLine(result: CommandCenterResult): boolean {
if (result.kind === "workspace" || result.kind === "agent") return true;
if (result.kind === "file") return false;
const presentation = result.contribution.presentation;
return presentation.kind === "action" && presentation.subtitle ? 2 : 1;
}
function resultHeight(result: CommandCenterResult, metrics: CommandCenterListMetrics): number {
const lines = resultLineCount(result);
if (lines === 3) return metrics.threeLineRow;
return lines === 2 ? metrics.twoLineRow : metrics.singleLineRow;
return presentation.kind === "action" && Boolean(presentation.subtitle);
}
/**
@@ -235,7 +211,10 @@ function resultScore(result: CommandCenterResult): CommandCenterScore | undefine
return result.kind === "contribution" ? result.score : undefined;
}
/** A built-in row that orders by how recently it saw activity. */
/** Rows shown when nothing has been typed, per pinned category. */
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
/** A built-in row (workspace or agent) that orders by how recently it saw activity. */
export interface CommandCenterRecentRow {
activityAt: Date | null;
}
@@ -256,20 +235,44 @@ export function compareBuiltInScores(left: CommandCenterScore, right: CommandCen
}
/**
* Group contributions into their sections, ranked by the query. `includeQueryOnly` lists the
* contributions that otherwise wait for a query, for a list whose whole job is browsing them.
* Filter and rank already-built rows. Rows in, rows out — building them needs translations,
* navigation callbacks and the project tree, none of which matching cares about, so that stays
* with the caller and this stays testable without mounting React.
*
* Rows rank by `compareBuiltInScores`; rows that rank equally, including every row of an empty
* query, go most recent first.
*/
export function filterAndRankBuiltInResults<Row extends CommandCenterRecentRow>(
rows: readonly Row[],
query: string,
fieldsOf: (row: Row) => CommandCenterSearchFields,
): Row[] {
if (!query.trim()) {
return [...rows].sort(compareByRecency).slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
}
const scored: { row: Row; score: CommandCenterScore }[] = [];
for (const row of rows) {
const score = scoreSearchFields(query, fieldsOf(row));
if (score) scored.push({ row, score });
}
scored.sort(
(left, right) =>
compareBuiltInScores(left.score, right.score) || compareByRecency(left.row, right.row),
);
return scored.map((entry) => entry.row);
}
export function buildContributionSections(
contributions: readonly CommandCenterContribution[],
query: string,
options: { includeQueryOnly?: boolean } = {},
): CommandCenterResultSection[] {
const groups = new Map<string, MutableCommandCenterResultSection>();
const registryIndexById = new Map<string, number>();
const hasQuery = Boolean(query.trim());
for (const [index, contribution] of contributions.entries()) {
if (contribution.visibility === "query" && !hasQuery && !options.includeQueryOnly) continue;
if (contribution.visibility === "query" && !hasQuery) continue;
let score: CommandCenterScore | undefined;
if (hasQuery) {
// The score is the filter: null means no token found a home, so the row is out.
@@ -348,7 +351,7 @@ export function projectCommandCenterRows(
offset += sectionHeight;
}
for (const result of section.results) {
const height = resultHeight(result, metrics);
const height = hasSubtitleLine(result) ? metrics.twoLineRow : metrics.singleLineRow;
offsets.push(offset);
rowIndexByResultId.set(result.id, rows.length);
rows.push({ kind: "result", key: result.id, result, height });
@@ -1,181 +0,0 @@
import { describe, expect, it } from "vitest";
import type { CommandCenterContribution } from "./contributions";
import type { CommandCenterFileResult, CommandCenterWorkspaceResult } from "./results";
import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics";
import { projectCommandCenterRows } from "./results";
import { buildCommandCenterSections } from "./sections";
import type { CommandCenterTab } from "./tabs";
import { createWorkspaceRanker } from "./workspace-search";
function workspace(
id: string,
options: {
daysAgo?: number;
title?: string;
archived?: boolean;
background?: boolean;
agentTitle?: string;
} = {},
): CommandCenterWorkspaceResult {
return {
kind: "workspace",
id: `workspace:server:${id}`,
serverId: "server",
workspaceId: id,
title: options.title ?? id,
subtitle: "project · main",
project: { viewKey: "project", name: "project", iconDataUri: null },
changeRequestNumber: null,
activityAt: new Date(Date.UTC(2026, 9, 30) - (options.daysAgo ?? 0) * 86_400_000),
agents: options.agentTitle
? [{ id: `${id}-agent`, title: options.agentTitle, provider: "mock" }]
: [],
matchedAgent: null,
archived: options.archived ?? false,
background: options.background ?? false,
run: () => undefined,
openAgent: () => undefined,
};
}
function action(id: string, group: string, visibility: "always" | "query" = "always") {
return {
id,
group,
groupRank: group === "workspace" ? 0 : 1,
rank: 0,
keywords: [],
visibility,
run: () => undefined,
presentation: { kind: "action", title: id, sectionTitle: group },
} satisfies CommandCenterContribution;
}
const file: CommandCenterFileResult = {
kind: "file",
id: "file:src/index.ts",
filePath: "src/index.ts",
title: "index.ts",
subtitle: "src",
run: () => undefined,
};
const workspaces = [
workspace("current", { daysAgo: 0 }),
workspace("payments", { daysAgo: 1, title: "Payments refactor" }),
// Started by automation; more recent than the workspaces below it.
workspace("nightly", { daysAgo: 1.5, title: "Nightly triage", background: true }),
workspace("onboarding", { daysAgo: 2, title: "Onboarding flow", agentTitle: "Add receipts" }),
workspace("docs", { daysAgo: 3, title: "Docs site" }),
workspace("search", { daysAgo: 4, title: "Search ranking" }),
];
const archived = [workspace("billing", { title: "Billing cleanup", archived: true })];
const contributions = [
action("New agent", "workspace"),
action("Settings", "actions"),
action("Group by status", "actions", "query"),
];
function listed(
tab: CommandCenterTab,
query: string,
options: { archivedMatchedByHost?: boolean; showBackground?: boolean } = {},
) {
const sections = buildCommandCenterSections({
tab,
query,
rankWorkspaces: createWorkspaceRanker(),
workspaces,
archivedWorkspaces: query ? archived : [],
archivedMatchedByHost: options.archivedMatchedByHost ?? true,
showBackground: options.showBackground ?? false,
currentWorkspaceId: "workspace:server:current",
contributions,
files: [file],
titles: { recentWorkspaces: "Recent workspaces", workspaces: "Workspaces", files: "Files" },
});
return projectCommandCenterRows(sections, COMMAND_CENTER_LIST_METRICS.desktop).rows.map((row) =>
row.kind === "section" ? `# ${row.title ?? ""}` : row.key,
);
}
describe("command center tabs", () => {
it("All leads with the three most recent workspaces, then actions, before a query", () => {
expect(listed("all", "")).toEqual([
"# Recent workspaces",
"workspace:server:payments",
"workspace:server:onboarding",
"workspace:server:docs",
"# workspace",
"New agent",
"# actions",
"Settings",
]);
});
it("lists background workspaces unqueried only as the sidebar does; a query finds them", () => {
expect(listed("all", "")).not.toContain("workspace:server:nightly");
expect(listed("all", "", { showBackground: true })).not.toContain("workspace:server:nightly");
expect(listed("workspaces", "")).not.toContain("workspace:server:nightly");
expect(listed("workspaces", "", { showBackground: true })).toContain(
"workspace:server:nightly",
);
expect(listed("all", "nightly")).toContain("workspace:server:nightly");
expect(listed("workspaces", "nightly")).toContain("workspace:server:nightly");
});
it("All ranks every kind of result for a query, archived workspaces after active ones", () => {
expect(listed("all", "i")).toEqual(
expect.arrayContaining(["Settings", "file:src/index.ts", "workspace:server:billing"]),
);
const rows = listed("all", "ing");
const workspaceRows = rows.filter((row) => row.startsWith("workspace:"));
expect(workspaceRows.at(-1)).toBe("workspace:server:billing");
expect(rows).not.toContain("# Recent workspaces");
});
it("Workspaces lists every workspace, current included, most recent first", () => {
expect(listed("workspaces", "")).toEqual([
"# Workspaces",
"workspace:server:current",
"workspace:server:payments",
"workspace:server:onboarding",
"workspace:server:docs",
"workspace:server:search",
]);
});
it("Workspaces finds a workspace through its agent and appends archived matches", () => {
expect(listed("workspaces", "receipts")).toEqual([
"# Workspaces",
"workspace:server:onboarding",
"workspace:server:billing",
]);
});
it("keeps archived rows the host did not match for this query only if they match it", () => {
// The previous query's archived results stand in while the next search loads.
expect(listed("workspaces", "receipts", { archivedMatchedByHost: false })).toEqual([
"# Workspaces",
"workspace:server:onboarding",
]);
expect(listed("workspaces", "billing", { archivedMatchedByHost: false })).toEqual([
"# Workspaces",
"workspace:server:billing",
]);
});
it("Files lists only files", () => {
expect(listed("files", "")).toEqual(["# Files", "file:src/index.ts"]);
});
it("Actions lists every action, including those that otherwise wait for a query", () => {
expect(listed("actions", "")).toEqual([
"# workspace",
"New agent",
"# actions",
"Settings",
"Group by status",
]);
});
});

Some files were not shown because too many files have changed in this diff Show More