mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 04:45:11 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7ed5e555 | ||
|
|
a0d8eb66f8 | ||
|
|
84bb8b8cd7 | ||
|
|
6f685d37be | ||
|
|
286d528b71 | ||
|
|
8ff657cd4a | ||
|
|
45a991415f | ||
|
|
1ee13fff51 | ||
|
|
635b63a22b | ||
|
|
008eeaf18e | ||
|
|
ed8aa9ada8 | ||
|
|
7286d3ef81 | ||
|
|
a349856600 | ||
|
|
6016d3582e | ||
|
|
c1d7d56b71 | ||
|
|
2bd9e9d87f | ||
|
|
6e2fee462f | ||
|
|
cec92d0be6 | ||
|
|
b407990699 | ||
|
|
fc36438f03 | ||
|
|
0fb6473a4b | ||
|
|
a730ad1924 | ||
|
|
42c7b7f886 | ||
|
|
85fb5b436d | ||
|
|
c81f38d7ca | ||
|
|
be487ce65f | ||
|
|
0e0ebc7c9e | ||
|
|
7f3967d288 | ||
|
|
ade902f195 | ||
|
|
1bd94ac2b0 | ||
|
|
7deb338d4c | ||
|
|
2fa0e4b169 | ||
|
|
d11239fd95 |
@@ -186,7 +186,8 @@ 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` covers Android; iOS has no native test harness.
|
||||
`VisibleContentPositionClampTest` and `SelectableTextTouchTest` cover 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
|
||||
@@ -206,5 +207,16 @@ 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
@@ -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 `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:
|
||||
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:
|
||||
|
||||
```bash
|
||||
cd packages/app/android
|
||||
|
||||
+7
-7
@@ -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 uses React Native’s press lifecycle on native with a short press delay so scrolling cancels pending feedback. 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 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.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
@@ -132,7 +132,7 @@ A section or group explains itself through the `info` prop on `<SettingsSection>
|
||||
|
||||
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
||||
|
||||
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. A row that opens something (`<SettingsDetailRow>`, a collapsible row's header) is at least as tall as one holding a default control, so a list where only some of those rows carry a switch keeps one row height. Other rows take their content's height. 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 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.
|
||||
|
||||
@@ -205,11 +205,11 @@ Terminology:
|
||||
|
||||
## 11. States
|
||||
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` leads the line it relates to, never trails a label: a settings row's hint takes `hintStatus="busy"` while its work runs, and `hintStatus="pending"` holds the line with a skeleton until its value is read (`packages/app/src/components/settings/row-header.tsx`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
|
||||
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `sm` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
|
||||
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/components/ui/form-field.tsx`). A settings row that is broken says so in its hint with `hintStatus="error"`: a red dot and the error in `statusDanger`.
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||
|
||||
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||
|
||||
@@ -231,13 +231,13 @@ Changing state must not move the layout. A row that grows when its badge arrives
|
||||
|
||||
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
|
||||
|
||||
A trailing chevron opens a detail, as a page or a sheet (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the pressable; use `<SettingsDetailRow>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx`), provider row (`packages/app/src/provider-settings/internal/provider-row.tsx`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
|
||||
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
|
||||
|
||||
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
|
||||
|
||||
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
|
||||
|
||||
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles passes its `<Switch>` as the `control` of `<SettingsDetailRow>`, before the chevron; the switch stops the press from reaching the row (`packages/app/src/provider-settings/internal/provider-row.tsx`). A list the user adds to ends with a muted `<SettingsAddRow>`. Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
|
||||
|
||||
@@ -286,7 +286,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
| Pattern | Reference |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/provider-settings/internal/provider-list.tsx` |
|
||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
||||
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
||||
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
||||
|
||||
+1
-1
@@ -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 durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
- **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.
|
||||
- **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:
|
||||
|
||||
+39
-9
@@ -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.tsx` | 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/` | 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,6 +65,10 @@ 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
|
||||
@@ -280,6 +284,22 @@ 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
|
||||
@@ -422,9 +442,11 @@ 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 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`.
|
||||
- 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`.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
@@ -453,6 +475,14 @@ 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.
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ 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
|
||||
|
||||
@@ -510,18 +510,6 @@ before calling status; it exposes your diagnostic through the normal provider di
|
||||
A command-backed provider without status is available when its executable resolves. Providers with
|
||||
neither command nor status retain connection-based availability.
|
||||
|
||||
Implement optional `installation` to fill the **Installation** section of provider settings. The
|
||||
bridge sends each callback as a `provider.installation.*` request carrying the launch `status`
|
||||
receives plus `resolved`. `resolved: false` means the executable was not found and `command` is
|
||||
the configured name, so answer `not_installed` instead of spawning it. Inspect and update-check
|
||||
requests use the 30 s plugin request timeout; the daemon's 25 s update-check deadline sits below
|
||||
it, so a slow check reports that deadline. `update` has no request timeout and streams output
|
||||
through `provider.installation.report`. When the daemon stops waiting on any of the three, it
|
||||
sends `provider.installation.cancel`, which aborts the callback's `signal`.
|
||||
The rules shared with built-ins are in [providers.md](providers.md#installation-and-updates); the
|
||||
author contract is in the
|
||||
[public provider guide](../public-docs/plugins/providers.md#report-installation-and-updates).
|
||||
|
||||
A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label,
|
||||
description, and model configuration through the normal provider registry. `enabled: false` disables
|
||||
selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin,
|
||||
|
||||
@@ -263,50 +263,6 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
|
||||
---
|
||||
|
||||
## Installation and updates
|
||||
|
||||
The provider list and the **Installation** section of a provider's page read `AgentClient.installation`. Built-ins set
|
||||
it on the client; plugins set `ProviderRegistration.installation`, which the plugin bridge maps to
|
||||
the same interface. `ProviderInstallations` in `packages/server/src/server/agent/provider-installation/`
|
||||
owns deadlines, caching and the one running update per provider, so a provider only answers.
|
||||
|
||||
Declare only what the vendor offers:
|
||||
|
||||
- `installUrl` and `updateUrl` link the vendor's own install and update docs.
|
||||
- `inspect` reports the version the CLI prints. Versions are opaque: Paseo shows them as written
|
||||
and never parses or compares them.
|
||||
- `checkForUpdates` asks the source the CLI's own updater reads, such as the vendor's release
|
||||
endpoint or the CLI's `update --check`. Leave it out when that source is inside the binary or
|
||||
depends on how the CLI was installed, which is why Copilot and OpenCode have no check. Do not
|
||||
build a release lookup of your own.
|
||||
- `update` runs the CLI's own update command and passes its output through, using
|
||||
`runUpdateCommand`. Paseo adds no verification, signature check or rollback, so the update does
|
||||
exactly what the same command does in a terminal.
|
||||
|
||||
The three calls are separate because they cost differently. `inspect` runs for every provider
|
||||
whenever the provider list shows, because each row states its version; a provider's page shares the
|
||||
row's request. It has a 20 s deadline and a 30 s cache. `checkForUpdates` usually reaches the
|
||||
network, so the app asks only after `inspect` reports the CLI installed, and a conclusive answer
|
||||
holds for an hour while `inspect` reports the same version string. Report every part of the version
|
||||
the check compares, such as Muse's build, or a CLI updated outside Paseo keeps its stale answer. `update` runs for minutes, streams output, and is aborted only when the daemon
|
||||
stops.
|
||||
|
||||
The update check only informs. Once `inspect` reports the CLI installed, the app offers **Update**
|
||||
for a provider that declares `update`, or **Update instructions** for one that declares only
|
||||
`updateUrl`, whatever the check answers and whether the provider has one. The check's answer shows
|
||||
beside the action when it has one. Copilot and OpenCode have no check, so they show **Update** alone.
|
||||
|
||||
Write the success message for the provider's process model. A CLI that Paseo starts once per agent
|
||||
uses `updatedForNewAgents()`. OpenCode agents share one server that keeps the old binary running,
|
||||
so its message asks the user to restart the daemon.
|
||||
|
||||
`createCliInstallation()` covers a CLI that prints `--version` and updates with a subcommand. It
|
||||
resolves the configured command, so a profile that extends a built-in reports and updates the CLI
|
||||
that profile launches. Clients for `extends: "acp"` providers declare no installation.
|
||||
|
||||
Test with fake executables, as `providers/provider-update-checks.test.ts` does. Running a real
|
||||
updater during development replaces the CLI on your machine.
|
||||
|
||||
## Usage sources
|
||||
|
||||
See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the
|
||||
|
||||
@@ -95,10 +95,11 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
|
||||
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
|
||||
|
||||
The daemon validates the epoch and locates the projected item at the selected position. A fork
|
||||
includes projected items through that checkpoint. If an item 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.
|
||||
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.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
expectProviderInstalledInSettings,
|
||||
findAcpCatalogProvider,
|
||||
installAcpCatalogProvider,
|
||||
openAddProvider,
|
||||
openProviderDetail,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
seedSavedSettingsHosts,
|
||||
@@ -48,7 +47,7 @@ test.describe("ACP provider catalog", () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProvider(page);
|
||||
await openAddProviderArea(page);
|
||||
|
||||
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
|
||||
@@ -70,7 +69,7 @@ test("adds fx from the catalog with only a config entry", async ({ page }, testI
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProvider(page);
|
||||
await openAddProviderArea(page);
|
||||
|
||||
await findAcpCatalogProvider(page, "fx");
|
||||
await page.screenshot({ path: testInfo.outputPath("fx-catalog.png") });
|
||||
@@ -105,7 +104,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await openAddProvider(page);
|
||||
await openAddProviderArea(page);
|
||||
await findAcpCatalogProvider(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) });
|
||||
await installAcpCatalogProvider(page, "Hoplite");
|
||||
@@ -128,7 +127,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
testInfo.outputPath(`hoplite-removed-${theme}.json`),
|
||||
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
|
||||
);
|
||||
await openAddProvider(page);
|
||||
await openAddProviderArea(page);
|
||||
await findAcpCatalogProvider(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) });
|
||||
await installAcpCatalogProvider(page, "Hoplite");
|
||||
@@ -141,11 +140,11 @@ for (const theme of ["light", "dark"] as const) {
|
||||
}
|
||||
|
||||
async function removeHoplite(page: import("@playwright/test").Page): Promise<void> {
|
||||
await openProviderDetail(page, "Hoplite");
|
||||
await page.getByTestId("provider-actions-hoplite").click();
|
||||
page.once("dialog", (dialog) => {
|
||||
void dialog.accept();
|
||||
});
|
||||
await page.getByTestId("provider-remove").click();
|
||||
await page.getByTestId("provider-remove-hoplite").click();
|
||||
}
|
||||
|
||||
test("unsupported host asks for an update without writing Hoplite config", async ({
|
||||
@@ -165,7 +164,7 @@ test("unsupported host asks for an update without writing Hoplite config", async
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, outdatedDaemon.serverId);
|
||||
await openSettingsHostSection(page, outdatedDaemon.serverId, "providers");
|
||||
await openAddProvider(page);
|
||||
await openAddProviderArea(page);
|
||||
await page.getByRole("button", { name: "Add Hoplite", exact: true }).click();
|
||||
const errorToast = page.getByTestId("app-toast-message");
|
||||
await expect(errorToast).toHaveText("Update the host to add Hoplite.");
|
||||
|
||||
@@ -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 agent", async ({
|
||||
test("agent list age advances and command center says just now for a fresh workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -23,10 +23,10 @@ test("agent list age advances and command center says just now for a fresh agent
|
||||
|
||||
const commandCenter = await openCommandCenter(page);
|
||||
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
|
||||
const agentResult = commandCenter.getByTestId(
|
||||
`command-center-agent-${getServerId()}:${session.agentId}`,
|
||||
const workspaceResult = commandCenter.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${session.workspaceId}`,
|
||||
);
|
||||
await expect(agentResult.getByTestId("command-center-result-timestamp")).toContainText(
|
||||
await expect(workspaceResult.getByTestId("command-center-result-timestamp")).toContainText(
|
||||
"just now",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { openSettings } from "../support/helpers/app";
|
||||
import {
|
||||
closeCommandCenter,
|
||||
expectCommandCenterTab,
|
||||
expectCommandCenterTabs,
|
||||
openCommandCenter,
|
||||
selectCommandCenterTab,
|
||||
} from "../support/helpers/command-center";
|
||||
import {
|
||||
delayDirectorySuggestionResponses,
|
||||
holdDirectorySuggestionResponses,
|
||||
@@ -17,6 +24,7 @@ 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 =
|
||||
@@ -67,7 +75,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 expect(panel.getByRole("button", { name: "Files" })).toBeVisible();
|
||||
await expectCommandCenterTab(panel, "Files");
|
||||
await startCommandCenterLayoutObservation(page);
|
||||
|
||||
await panel.getByTestId("command-center-input").fill(FILE_NAME);
|
||||
@@ -97,26 +105,90 @@ test("workspace file search stays geometrically stable through delayed loading a
|
||||
}
|
||||
});
|
||||
|
||||
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
|
||||
test("command center tabs rotate with Tab and never move the header", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-file-search-scope-",
|
||||
title: "Scope chip height",
|
||||
repoPrefix: "command-center-tabs-",
|
||||
title: "Tab rotation",
|
||||
});
|
||||
|
||||
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 scoped = await header.boundingBox();
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
const unscoped = 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");
|
||||
});
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
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);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
searchCommandCenter,
|
||||
warmCommandCenterRows,
|
||||
expectOnlyCommandCenterRowRendered,
|
||||
expectCommandCenterAgentWindow,
|
||||
scrollToOldestCommandCenterAgent,
|
||||
expectCommandCenterWorkspaceWindow,
|
||||
scrollToOldestCommandCenterWorkspace,
|
||||
} from "../support/helpers/command-center-performance";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
expectPrimaryCommandCenterActions,
|
||||
expectTightTwoLineResultSpacing,
|
||||
expectVisibleKeyboardNavigationNotToScroll,
|
||||
expectWorkspaceResultsToBeLimitedUntilSearch,
|
||||
expectRecentWorkspacesUntilSearch,
|
||||
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);
|
||||
// The empty query lists the most recent workspaces, so the last one seeded leads it.
|
||||
// All leads with the most recent workspaces, so the last one seeded leads it.
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
|
||||
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
|
||||
await expectRecentWorkspacesUntilSearch(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,42 +76,50 @@ test("Command Center preserves manual scrolling through live updates and resumes
|
||||
}
|
||||
});
|
||||
|
||||
test("live search updates render only the changed row and keep agents virtualized", async ({
|
||||
test("live search updates render only the changed row and keep workspaces 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 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 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 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("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 agent");
|
||||
await warmCommandCenterRows(page);
|
||||
await scrollToOldestCommandCenterAgent(panel);
|
||||
await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible();
|
||||
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
|
||||
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);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ 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 { openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
commandCenterSection,
|
||||
expectCommandCenterTab,
|
||||
openCommandCenter,
|
||||
selectCommandCenterTab,
|
||||
} 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";
|
||||
@@ -21,9 +26,7 @@ const AGENT_TITLE = "Fix checkout retries";
|
||||
test.describe("Command center workspaces", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent and workspace results retain their icons and overlay live status", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("workspace results overlay live status", async ({ page }) => {
|
||||
const workspace = await seedRunningMockAgentWorkspace({
|
||||
repoPrefix: "command-center-status-",
|
||||
title: "Review checkout retries",
|
||||
@@ -34,15 +37,13 @@ test.describe("Command center workspaces", () => {
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectAgentTabActive(page, workspace.agentId);
|
||||
const panel = await openCommandCenter(page);
|
||||
const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`);
|
||||
// Recent workspaces leaves out the open one; the Workspaces tab lists every workspace.
|
||||
await selectCommandCenterTab(panel, "Workspaces");
|
||||
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();
|
||||
@@ -80,9 +81,11 @@ 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);
|
||||
@@ -92,65 +95,151 @@ 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);
|
||||
|
||||
// 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("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();
|
||||
});
|
||||
|
||||
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 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 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,
|
||||
}) => {
|
||||
@@ -229,7 +318,7 @@ test.describe("Compact sidebar inspection", () => {
|
||||
title: "Compact destination",
|
||||
});
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Compact root agent",
|
||||
@@ -246,7 +335,10 @@ 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-agent-${getServerId()}:${agent.id}`).click();
|
||||
await agentSearch
|
||||
.getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`)
|
||||
.getByTestId("command-center-workspace-agent")
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Message agent...", exact: true }),
|
||||
|
||||
@@ -787,9 +787,13 @@ test.describe("Composer autocomplete", () => {
|
||||
await input.press("ArrowDown");
|
||||
await input.press("ArrowDown");
|
||||
await input.press("h");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
||||
// 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 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 ");
|
||||
|
||||
@@ -125,12 +125,15 @@ 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 countBefore = await newTabs.count();
|
||||
const openedCount = await newTabs.count();
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(openedCount + 1);
|
||||
const countBefore = openedCount + 1;
|
||||
const draftCount = await countTabsOfKind(page, "draft");
|
||||
|
||||
await test.step("opening the plus menu leaves the current tabs intact", async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
addProjectFromSetup,
|
||||
continueSetup,
|
||||
expectSetupStep,
|
||||
expectSheetCoversSetupCard,
|
||||
finishSetup,
|
||||
goToSetupStep,
|
||||
hostGivesAgentsPaseoTools,
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
recordFirstLaunch,
|
||||
setupModal,
|
||||
setupPrimaryButton,
|
||||
setupSheet,
|
||||
showSetupAgainFromSettings,
|
||||
skipSetup,
|
||||
startSetupHost,
|
||||
@@ -37,11 +35,7 @@ import {
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { enableRelayAndExpectOffer } from "../support/helpers/pair-device";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
installAcpCatalogProvider,
|
||||
openAddProvider,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
// Midnight's muted foreground (#9a9db0), which the step description is drawn in.
|
||||
@@ -65,28 +59,11 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
|
||||
await test.step("Providers shows the same provider list as Settings", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Your providers");
|
||||
await expectSetupStep(page, "Your agents");
|
||||
await expect(setupModal(page).getByTestId("host-page-providers-card")).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("2-providers.png") });
|
||||
});
|
||||
|
||||
await test.step("a provider's detail and Add provider cover the setup card and lead back to it", async () => {
|
||||
await setupModal(page)
|
||||
.getByRole("button", { name: "Claude provider details", exact: true })
|
||||
.click();
|
||||
const detail = setupSheet(page, "provider-settings-sheet");
|
||||
await expectSheetCoversSetupCard(page, detail);
|
||||
await detail.getByRole("button", { name: "Back to providers", exact: true }).click();
|
||||
await expect(detail).toHaveCount(0);
|
||||
|
||||
await setupModal(page).getByTestId("provider-list-add").click();
|
||||
const add = setupSheet(page, "add-provider-sheet");
|
||||
await expectSheetCoversSetupCard(page, add);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(add).toHaveCount(0);
|
||||
await expectSetupStep(page, "Your providers");
|
||||
});
|
||||
|
||||
await test.step("with no agent history, Add project opens the regular flow and the folder shows as added", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
@@ -126,33 +103,6 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a provider removed in setup can be added back and removed again", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const host = await startSetupHost("re-add");
|
||||
const detail = setupSheet(page, "provider-settings-sheet");
|
||||
const remove = detail.getByTestId("provider-remove");
|
||||
page.on("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Your providers");
|
||||
|
||||
await openAddProvider(page);
|
||||
await installAcpCatalogProvider(page, "Junie");
|
||||
await remove.click();
|
||||
await expect(detail).toHaveCount(0);
|
||||
|
||||
// Adding it back opens the same provider's sheet, which must not still be removing it.
|
||||
await openAddProvider(page);
|
||||
await installAcpCatalogProvider(page, "Junie");
|
||||
await expect(remove).toHaveText("Remove");
|
||||
await expect(remove).toBeEnabled();
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new user goes from the loading screen straight into setup", async ({ page }) => {
|
||||
const host = await startSetupHost("first-launch");
|
||||
|
||||
@@ -195,7 +145,7 @@ test("changing the theme keeps the setup window in place on every step", async (
|
||||
|
||||
for (const title of [
|
||||
"Welcome to Paseo",
|
||||
"Your providers",
|
||||
"Your agents",
|
||||
"Add your projects",
|
||||
"Let agents use Paseo",
|
||||
"Take your agents with you",
|
||||
@@ -468,7 +418,7 @@ test("in the compact layout, Finish on Orchestration applies its switches", asyn
|
||||
await openAppAsNewUser(page, host);
|
||||
await step("Welcome to Paseo");
|
||||
await primary("Continue").click();
|
||||
await step("Your providers");
|
||||
await step("Your agents");
|
||||
await primary("Continue").click();
|
||||
await step("Add your projects");
|
||||
await primary("Continue").click();
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
findAcpCatalogProvider,
|
||||
openAddProvider,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
async function openProviderCatalog(page: Page, viewport?: { width: number; height: number }) {
|
||||
async function openProviderCatalog(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
if (viewport) await page.setViewportSize(viewport);
|
||||
await openAddProvider(page);
|
||||
await openAddProviderArea(page);
|
||||
}
|
||||
|
||||
async function expectProviderArtwork(
|
||||
@@ -26,11 +25,11 @@ async function expectProviderArtwork(
|
||||
name: string,
|
||||
) {
|
||||
await findAcpCatalogProvider(page, name);
|
||||
// Catalog rows have no semantic role of their own; scope artwork to the named provider's row.
|
||||
const icon = page.getByTestId(`provider-catalog-row-${id}`).locator("svg").first();
|
||||
// Catalog tiles have no semantic role; scope artwork to the named provider's tile.
|
||||
const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first();
|
||||
await expect(icon).toBeVisible();
|
||||
// Artwork draws at the provider list's icon size, so the catalog and the list share a rail.
|
||||
await expect.soft(icon).toHaveAttribute("width", "16");
|
||||
// Provider artwork is 24px; the generic PackagePlus fallback is 20px.
|
||||
await expect.soft(icon).toHaveAttribute("width", "24");
|
||||
const expectedPaths = await page.evaluate((xml) => {
|
||||
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
|
||||
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
|
||||
@@ -50,20 +49,3 @@ test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async
|
||||
await expectProviderArtwork(page, testInfo, "hermes", "Hermes");
|
||||
await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI");
|
||||
});
|
||||
|
||||
/** The space between a row's icon and its label, as drawn. */
|
||||
async function gapAfterIcon(row: Locator, label: string): Promise<number> {
|
||||
const icon = await row.locator("svg").first().boundingBox();
|
||||
const text = await row.getByText(label, { exact: true }).boundingBox();
|
||||
if (!icon || !text) throw new Error(`${label} has no icon or label on screen`);
|
||||
return text.x - (icon.x + icon.width);
|
||||
}
|
||||
|
||||
test("catalog rows keep the provider list's icon rail on a phone", async ({ page }) => {
|
||||
await openProviderCatalog(page, { width: 390, height: 844 });
|
||||
await findAcpCatalogProvider(page, "Kiro CLI");
|
||||
|
||||
const listGap = await gapAfterIcon(page.getByTestId("provider-row-claude"), "Claude");
|
||||
const catalogGap = await gapAfterIcon(page.getByTestId("provider-catalog-row-kiro"), "Kiro CLI");
|
||||
expect(catalogGap).toBe(listGap);
|
||||
});
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openCustomModels, removeCustomModel } from "../support/helpers/custom-models";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
|
||||
// A pressed control shows its own progress while the daemon answers, the way Button's `loading`
|
||||
// does: a spinner in place of its icon, no further presses and, on a labelled button, a
|
||||
// present-participle label.
|
||||
|
||||
const MODEL_ID = "in-flight-model";
|
||||
|
||||
test.describe("provider page work in flight", () => {
|
||||
test("says Adding... on the Add button while a custom model is saved", async ({ page }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openCustomModels(page);
|
||||
|
||||
await page.getByRole("button", { name: "Add model", exact: true }).click();
|
||||
const sheet = page.getByTestId("add-custom-model-sheet");
|
||||
await page.getByPlaceholder("e.g. openai/gpt-5").fill(MODEL_ID);
|
||||
gate.holdNextServerMessage("set_daemon_config_response");
|
||||
await sheet.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await gate.waitForHeldServerMessage("set_daemon_config_response");
|
||||
|
||||
const adding = sheet.getByRole("button", { name: "Adding...", exact: true });
|
||||
await expect(adding.getByRole("progressbar")).toBeVisible();
|
||||
await expect(adding).toBeDisabled();
|
||||
|
||||
gate.releaseHeldServerMessage("set_daemon_config_response");
|
||||
await expect(sheet).toBeHidden({ timeout: 30_000 });
|
||||
await removeCustomModel(page, MODEL_ID);
|
||||
});
|
||||
|
||||
test("shows the diagnostic report running again on its re-run button", async ({ page }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openCustomModels(page);
|
||||
|
||||
await page.getByRole("button", { name: "Diagnostic report", exact: true }).click();
|
||||
const sheet = page.getByTestId("provider-diagnostic-sheet");
|
||||
const rerun = sheet.getByRole("button", { name: "Refresh diagnostic", exact: true });
|
||||
await expect(rerun).toBeEnabled({ timeout: 30_000 });
|
||||
// Copy has a report to copy once the first run is in.
|
||||
await expect(sheet.getByRole("button", { name: "Copy diagnostic" })).toBeEnabled();
|
||||
|
||||
gate.holdNextServerMessage("provider_diagnostic_response");
|
||||
await rerun.click();
|
||||
await gate.waitForHeldServerMessage("provider_diagnostic_response");
|
||||
|
||||
const running = sheet.getByRole("button", { name: "Refreshing diagnostic", exact: true });
|
||||
await expect(running.getByRole("progressbar")).toBeVisible();
|
||||
await expect(running).toBeDisabled();
|
||||
|
||||
gate.releaseHeldServerMessage("provider_diagnostic_response");
|
||||
await expect(rerun).toBeEnabled({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,11 @@ import type { Dialog } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
expectProviderNotOnHost,
|
||||
installAcpCatalogProvider,
|
||||
leaveProviderDetail,
|
||||
openAddProvider,
|
||||
openProviderDetail,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
@@ -66,39 +62,13 @@ async function clickRemoveProviderAndAcceptWarning(page: Page): Promise<Dialog>
|
||||
expect(dialog.message()).toContain("This deletes the provider entry from config.json.");
|
||||
void dialog.accept();
|
||||
});
|
||||
await page.getByTestId("provider-remove").click();
|
||||
await page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`).click();
|
||||
if (!warning) {
|
||||
throw new Error("Expected a provider removal confirmation dialog, but none was shown.");
|
||||
}
|
||||
return warning;
|
||||
}
|
||||
|
||||
const MISSING_NOTICE = '[data-testid="provider-detail-missing"]';
|
||||
|
||||
/** Records whether the page ever says its provider isn't on the host, however briefly. */
|
||||
async function watchForMissingNotice(page: Page): Promise<void> {
|
||||
await page.evaluate((selector) => {
|
||||
const flags = window as unknown as { sawMissingNotice?: boolean };
|
||||
flags.sawMissingNotice = false;
|
||||
new MutationObserver(() => {
|
||||
if (document.querySelector(selector)) flags.sawMissingNotice = true;
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
}, MISSING_NOTICE);
|
||||
}
|
||||
|
||||
async function expectMissingNoticeNeverShown(page: Page): Promise<void> {
|
||||
const saw = await page.evaluate(
|
||||
() => (window as unknown as { sawMissingNotice?: boolean }).sawMissingNotice,
|
||||
);
|
||||
expect(saw).toBe(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The daemon publishes the provider list without the removed provider before it answers the config
|
||||
* patch. Holding the answer keeps the page in that window.
|
||||
*/
|
||||
const CONFIG_RESPONSE = "set_daemon_config_response";
|
||||
|
||||
test.describe("provider removal", () => {
|
||||
test("removes a custom provider from Settings", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
@@ -109,39 +79,20 @@ test.describe("provider removal", () => {
|
||||
try {
|
||||
await removeCustomProvider(client);
|
||||
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
|
||||
// A built-in provider has no Danger zone.
|
||||
await openProviderDetail(page, "Claude");
|
||||
await expect(page.getByTestId("provider-remove")).toHaveCount(0);
|
||||
await leaveProviderDetail(page);
|
||||
|
||||
await openAddProvider(page);
|
||||
await expect(page.getByTestId("provider-actions-claude")).toHaveCount(0);
|
||||
await openAddProviderArea(page);
|
||||
await installAcpCatalogProvider(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderSource(client, "custom");
|
||||
|
||||
await openProviderDetail(page, CUSTOM_PROVIDER.name);
|
||||
await expect(page.getByTestId("provider-remove")).toBeVisible();
|
||||
await watchForMissingNotice(page);
|
||||
gate.holdNextServerMessage(CONFIG_RESPONSE);
|
||||
await page.getByTestId(`provider-actions-${CUSTOM_PROVIDER.id}`).click();
|
||||
await expect(page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`)).toBeVisible();
|
||||
await clickRemoveProviderAndAcceptWarning(page);
|
||||
await gate.waitForHeldServerMessage(CONFIG_RESPONSE);
|
||||
await expectProviderSource(client, undefined);
|
||||
// Until the removal is answered, the page keeps the provider and the pressed button says so.
|
||||
const removeButton = page.getByTestId("provider-remove");
|
||||
await expect(removeButton).toHaveText("Removing...");
|
||||
await expect(removeButton.getByRole("progressbar")).toBeVisible();
|
||||
await expect(removeButton).toBeDisabled();
|
||||
await expect(page.getByTestId("provider-detail-title")).toHaveText(CUSTOM_PROVIDER.name);
|
||||
gate.releaseHeldServerMessage(CONFIG_RESPONSE);
|
||||
// Removing leaves the provider's page for the list, without first calling it missing.
|
||||
await expect(page.getByTestId("provider-list")).toBeVisible();
|
||||
await expectMissingNoticeNeverShown(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
@@ -149,33 +100,7 @@ test.describe("provider removal", () => {
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await removeCustomProvider(client).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("says a provider removed elsewhere while its page is open isn't on this host", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
|
||||
clientIdPrefix: "provider-removal-e2e",
|
||||
});
|
||||
|
||||
try {
|
||||
await removeCustomProvider(client);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProvider(page);
|
||||
await installAcpCatalogProvider(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, CUSTOM_PROVIDER.name);
|
||||
await openProviderDetail(page, CUSTOM_PROVIDER.name);
|
||||
|
||||
await removeCustomProvider(client);
|
||||
await expectProviderNotOnHost(page);
|
||||
await expectProviderSource(client, undefined);
|
||||
} finally {
|
||||
await removeCustomProvider(client).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
@@ -205,9 +130,7 @@ test.describe("provider removal", () => {
|
||||
exact: true,
|
||||
});
|
||||
await expect(pluginProviderRow).toBeVisible();
|
||||
await openProviderDetail(page, PLUGIN_PROVIDER.name);
|
||||
await expect(page.getByTestId("provider-remove")).toHaveCount(0);
|
||||
await leaveProviderDetail(page);
|
||||
await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
|
||||
|
||||
// The provider survives a removeProviders patch pointed at it: the
|
||||
// plugin, not config.json, owns the definition.
|
||||
|
||||
@@ -97,7 +97,7 @@ async function hasFocusWithin(locator: Locator): Promise<boolean> {
|
||||
async function expectProviderSettingsVisible(page: Page) {
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Diagnostic report" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Diagnostic", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function exerciseProviderSettingsStack(page: Page) {
|
||||
@@ -109,7 +109,7 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
await expect(page.getByPlaceholder("e.g. openai/gpt-5")).not.toBeVisible({ timeout: 10_000 });
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Diagnostic report" }).click();
|
||||
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
@@ -118,7 +118,7 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectProviderSettingsVisible(page);
|
||||
await expect(page.getByText("Checked just now", { exact: true })).toBeVisible({
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,9 +15,6 @@ import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
expectProviderNotOnHost,
|
||||
expectProviderPageLoaded,
|
||||
gotoProviderPage,
|
||||
openSettingsHost,
|
||||
openHostSection,
|
||||
expectHostLabelDisplayed,
|
||||
@@ -265,20 +262,6 @@ test.describe("Settings host page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("provider page from its URL", () => {
|
||||
test("loads a provider's page on a cold visit", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await gotoProviderPage(page, getServerId(), "claude");
|
||||
await expectProviderPageLoaded(page, "Claude");
|
||||
});
|
||||
|
||||
test("says a provider the host doesn't have isn't on this host", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await gotoProviderPage(page, getServerId(), "not-a-provider");
|
||||
await expectProviderNotOnHost(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("restart recovery defaults off and persists opt-in and custom prompt", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -14,6 +14,7 @@ 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,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
resizeSidebarToMinimum,
|
||||
rowArchiveButton,
|
||||
rowKebab,
|
||||
scrollSidebarListToEnd,
|
||||
undoButton,
|
||||
workspaceRow,
|
||||
workspaceTitleWidth,
|
||||
@@ -119,6 +121,41 @@ 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,
|
||||
}) => {
|
||||
|
||||
@@ -5,27 +5,34 @@ 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";
|
||||
|
||||
async function seedRiskyWorktree(
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
worktreeDirectory: string,
|
||||
): Promise<void> {
|
||||
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
async function seedRiskyWorktree(client: DaemonClient, 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,
|
||||
@@ -55,35 +62,189 @@ async function seedRiskyWorktree(
|
||||
const dirtyFile = path.join(worktreeDirectory, "DIRTY.md");
|
||||
await writeFile(dirtyFile, "# dirty\n");
|
||||
|
||||
const refreshed = await client.checkoutRefresh(worktreeDirectory);
|
||||
await refreshCheckout(client, worktreeDirectory);
|
||||
}
|
||||
|
||||
async function refreshCheckout(client: DaemonClient, directory: string): Promise<void> {
|
||||
const refreshed = await client.checkoutRefresh(directory);
|
||||
if (!refreshed.success) {
|
||||
throw new Error(`Failed to refresh checkout for ${worktreeDirectory}`);
|
||||
throw new Error(`Failed to refresh checkout for ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
// 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(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
answer: "accept" | "dismiss",
|
||||
openArchive: () => Promise<void>,
|
||||
): Promise<Dialog> {
|
||||
let warning: Dialog | undefined;
|
||||
page.once("dialog", (dialog) => {
|
||||
warning = dialog;
|
||||
void (answer === "accept" ? dialog.accept() : dialog.dismiss());
|
||||
const warning = page.waitForEvent("dialog", { timeout: 10_000 }).then(async (dialog) => {
|
||||
await (answer === "accept" ? dialog.accept() : dialog.dismiss());
|
||||
return dialog;
|
||||
});
|
||||
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
||||
if (!warning) {
|
||||
throw new Error("Expected an archive confirmation dialog, but none was shown.");
|
||||
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;
|
||||
}
|
||||
return warning;
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let client: DaemonClient;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
@@ -103,36 +264,53 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
async function createWorktree(slug: string) {
|
||||
const project = await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `archive-risk-${Date.now()}`,
|
||||
slug: `${slug}-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
return { ...worktree, project };
|
||||
}
|
||||
|
||||
await seedRiskyWorktree(client, worktree.workspaceDirectory);
|
||||
|
||||
async function openSidebarRow(page: Page, workspaceId: string): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
|
||||
}
|
||||
|
||||
const firstWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "dismiss");
|
||||
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 firstWarning = await answerArchiveWarning(page, "dismiss", () =>
|
||||
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
|
||||
);
|
||||
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 expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${serverId}:${worktree.workspaceId}`),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expectWorktreeKept(page, worktree);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
const secondWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "accept");
|
||||
const secondWarning = await answerArchiveWarning(page, "accept", () =>
|
||||
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
|
||||
);
|
||||
expect(secondWarning.message()).toContain("Uncommitted changes");
|
||||
expect(secondWarning.message()).toContain("1 unpushed commit");
|
||||
|
||||
@@ -143,4 +321,128 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface OlderTimelinePagesGate {
|
||||
getRepeatedEntryCount(): number;
|
||||
getOwnedEntryCountContaining(text: string): number;
|
||||
releasePage(pageNumber: number): void;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DaemonHydrationGate {
|
||||
@@ -371,15 +370,6 @@ 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));
|
||||
@@ -393,7 +383,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
sessionMessage.direction === "before"
|
||||
) {
|
||||
requestCount += 1;
|
||||
resolveRequestWaiters();
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
@@ -439,14 +428,6 @@ 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"]',
|
||||
scope: '[data-testid="command-center-files-scope"]',
|
||||
tabs: '[data-testid="command-center-tabs"]',
|
||||
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 < 60; index += 1) {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
agents.push(
|
||||
await createMockIdleAgent(seed.client, {
|
||||
cwd: seed.repoPath,
|
||||
@@ -49,11 +49,27 @@ 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.
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => {
|
||||
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
|
||||
export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) {
|
||||
@@ -73,16 +89,17 @@ export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: s
|
||||
expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } });
|
||||
}
|
||||
|
||||
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();
|
||||
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();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(count).toBeLessThan(total);
|
||||
}
|
||||
|
||||
export async function scrollToOldestCommandCenterAgent(panel: Locator) {
|
||||
export async function scrollToOldestCommandCenterWorkspace(panel: Locator) {
|
||||
await panel.getByTestId("command-center-results").evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
|
||||
@@ -22,14 +22,9 @@ export async function observeCommandCenterScroll(page: Page): Promise<void> {
|
||||
|
||||
const discoverPanel = new MutationObserver(() => {
|
||||
const panel = document.querySelector<HTMLElement>('[data-testid="command-center-panel"]');
|
||||
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;
|
||||
const viewport = panel?.querySelector<HTMLElement>('[data-testid="command-center-results"]');
|
||||
const firstResult = viewport?.querySelector<HTMLElement>('[role="button"]');
|
||||
if (!viewport || !firstResult) return;
|
||||
|
||||
discoverPanel.disconnect();
|
||||
let frame = 0;
|
||||
@@ -119,15 +114,8 @@ export async function expectVisibleKeyboardNavigationNotToScroll(
|
||||
for (let index = 0; index < 4; index += 1) await page.keyboard.press("ArrowDown");
|
||||
|
||||
const scrollSamples = await panel
|
||||
.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");
|
||||
|
||||
.getByTestId("command-center-results")
|
||||
.evaluate(async (viewport) => {
|
||||
const samples: number[] = [];
|
||||
for (let frame = 0; frame < 20; frame += 1) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
@@ -139,7 +127,8 @@ export async function expectVisibleKeyboardNavigationNotToScroll(
|
||||
expect(Math.max(...scrollSamples)).toBe(0);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locator): Promise<void> {
|
||||
/** All leads with three recent workspaces; a query searches every workspace. */
|
||||
export async function expectRecentWorkspacesUntilSearch(panel: Locator): Promise<void> {
|
||||
const workspaces = panel.locator(
|
||||
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"])',
|
||||
);
|
||||
@@ -150,9 +139,11 @@ export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locato
|
||||
),
|
||||
).size;
|
||||
|
||||
await expect.poll(distinctWorkspaceCount).toBe(5);
|
||||
await expect(panel.getByText("Recent workspaces", { exact: true })).toBeVisible();
|
||||
await expect.poll(distinctWorkspaceCount).toBe(3);
|
||||
|
||||
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,5 +1,20 @@
|
||||
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();
|
||||
@@ -12,3 +27,46 @@ 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 });
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
await searchModels(page, id);
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
|
||||
await expect(remove).toBeInViewport({ ratio: 1 });
|
||||
await remove.click();
|
||||
@@ -33,20 +33,8 @@ export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
|
||||
await searchModels(page, id);
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the model list and scrolls the sheet down to the custom models, since other sections push
|
||||
* them below the fold at phone width. Only the section heading is scrolled into view: scrolling the
|
||||
* row itself would also slide its card sideways and hide a Remove button pushed past the right edge.
|
||||
*/
|
||||
async function searchModels(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await page
|
||||
.getByText("Custom models", { exact: true })
|
||||
.evaluate((heading) => heading.scrollIntoView({ block: "start" }));
|
||||
}
|
||||
|
||||
@@ -33,8 +33,10 @@ async function createRunningMockAgent(
|
||||
return { id: agent.id, title };
|
||||
}
|
||||
|
||||
async function openCommandCenter(page: Page): Promise<void> {
|
||||
// Agents are found through their workspace: the query names the agent, the row shows it.
|
||||
async function searchCommandCenter(page: Page, query: string): Promise<void> {
|
||||
await page.getByTestId("sidebar-search").click();
|
||||
await page.getByTestId("command-center-input").fill(query);
|
||||
}
|
||||
|
||||
export class DirectoryBootstrapScenario {
|
||||
@@ -78,8 +80,10 @@ export class DirectoryBootstrapScenario {
|
||||
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
|
||||
await expect(workspaceLink).toHaveCount(1);
|
||||
await expect(workspaceLink).toBeVisible();
|
||||
await openCommandCenter(this.page);
|
||||
const agentLink = this.page.getByText(agent.title, { exact: true });
|
||||
await searchCommandCenter(this.page, agent.title);
|
||||
const agentLink = this.page
|
||||
.getByTestId("command-center-workspace-agent")
|
||||
.getByText(agent.title, { exact: true });
|
||||
await expect(agentLink).toHaveCount(1);
|
||||
await expect(agentLink).toBeVisible();
|
||||
await this.page.keyboard.press("Escape");
|
||||
@@ -111,8 +115,10 @@ export class DirectoryBootstrapScenario {
|
||||
const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true });
|
||||
await expect(workspaceLink).toHaveCount(1);
|
||||
await expect(workspaceLink).toBeVisible();
|
||||
await openCommandCenter(this.page);
|
||||
const agentLink = this.page.getByText(agent.title, { exact: true });
|
||||
await searchCommandCenter(this.page, agent.title);
|
||||
const agentLink = this.page
|
||||
.getByTestId("command-center-workspace-agent")
|
||||
.getByText(agent.title, { exact: true });
|
||||
await expect(agentLink).toHaveCount(1);
|
||||
await expect(agentLink).toBeVisible();
|
||||
await agentLink.click();
|
||||
|
||||
@@ -86,17 +86,6 @@ export function setupModal(page: Page): Locator {
|
||||
return page.getByTestId("onboarding-modal").getByRole("dialog");
|
||||
}
|
||||
|
||||
/** A sheet opened from a setup step, which should sit exactly on the setup card. */
|
||||
export function setupSheet(page: Page, testID: "provider-settings-sheet" | "add-provider-sheet") {
|
||||
return page.getByTestId(testID).getByRole("dialog");
|
||||
}
|
||||
|
||||
export async function expectSheetCoversSetupCard(page: Page, sheet: Locator): Promise<void> {
|
||||
await expect(sheet).toBeVisible();
|
||||
const card = await setupModal(page).boundingBox();
|
||||
await expect.poll(() => sheet.boundingBox()).toEqual(card);
|
||||
}
|
||||
|
||||
export async function expectSetupStep(page: Page, title: string): Promise<void> {
|
||||
await expect(setupModal(page).getByRole("heading", { name: title, exact: true })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
|
||||
@@ -106,12 +106,10 @@ export async function verifyProviderSettings({
|
||||
await test.step("provider settings render the registered icon", async () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
// The row's button lies under its content, so the icon is in the row, beside the button.
|
||||
const opener = page.getByRole("button", {
|
||||
name: "Direct provider example provider details",
|
||||
exact: true,
|
||||
});
|
||||
await expectProviderIcon(page.getByTestId(/^provider-row-/).filter({ has: opener }), iconPaths);
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: "Direct provider example provider details", exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "provider-settings");
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
});
|
||||
|
||||
@@ -507,8 +507,8 @@ export async function serveJson(page: Page, url: string, body: unknown): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAddProvider(page: Page): Promise<void> {
|
||||
await page.getByTestId("provider-list-add").click();
|
||||
export async function openAddProviderArea(page: Page): Promise<void> {
|
||||
await page.getByTestId("host-page-add-provider-card").scrollIntoViewIfNeeded();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -520,59 +520,17 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
|
||||
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await findAcpCatalogProvider(page, providerName);
|
||||
await page.getByRole("button", { name: `Add ${providerName}`, exact: true }).click();
|
||||
// The sheet closes once the add is done. Adding probes the new provider, which can outlast an
|
||||
// assertion's default wait.
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Adding opens the new provider's page; its row waits in the list behind it. */
|
||||
export async function expectProviderInstalledInSettings(
|
||||
page: Page,
|
||||
providerName: string,
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("provider-detail-title")).toHaveText(providerName);
|
||||
await page.getByTestId("provider-detail-back").click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${providerName} provider details`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openProviderDetail(page: Page, providerName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `${providerName} provider details`, exact: true }).click();
|
||||
await expect(page.getByTestId("provider-detail-title")).toHaveText(providerName);
|
||||
}
|
||||
|
||||
/** Load a provider's Settings page from its URL, as a reload or a shared link does. */
|
||||
export async function gotoProviderPage(
|
||||
page: Page,
|
||||
serverId: string,
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
await page.goto(
|
||||
`/settings/hosts/${encodeURIComponent(serverId)}/providers/${encodeURIComponent(providerId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectProviderPageLoaded(page: Page, providerName: string): Promise<void> {
|
||||
await expect(page.getByTestId("provider-detail-title")).toHaveText(providerName, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Provider status")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectProviderNotOnHost(page: Page): Promise<void> {
|
||||
await expect(page.getByText("This provider isn't on this host")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function leaveProviderDetail(page: Page): Promise<void> {
|
||||
await page.getByTestId("provider-detail-back").click();
|
||||
await expect(page.getByTestId("provider-list")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostNoDaemonLifecycleRow(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -427,7 +427,11 @@ export async function holdOlderHistoryPages(
|
||||
const loader = page.getByTestId("load-older-history-spinner");
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
await expect
|
||||
.poll(() => gate.getRequestCount(), {
|
||||
message: `older history requests ${count} page(s)`,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(count);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
|
||||
@@ -162,3 +162,38 @@ 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)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+54
-1
@@ -1,10 +1,12 @@
|
||||
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;
|
||||
@@ -12,7 +14,7 @@ import com.facebook.react.views.scroll.ReactScrollView;
|
||||
import com.facebook.react.views.scroll.ReactScrollViewHelper;
|
||||
|
||||
/**
|
||||
* React Native's vertical scroll view with two corrections. Java, because the content-position
|
||||
* React Native's vertical scroll view with five corrections. Java, because the content-position
|
||||
* config type is internal to React Native's Kotlin module.
|
||||
*/
|
||||
final class PaseoScrollView extends ReactScrollView {
|
||||
@@ -27,6 +29,7 @@ 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);
|
||||
@@ -51,6 +54,56 @@ 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) {
|
||||
|
||||
@@ -23,6 +23,12 @@ 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: {
|
||||
|
||||
@@ -49,8 +49,21 @@ afterEach(() => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
function mountTimeline(historyMounted: StreamItem[]) {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
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 });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const input: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
@@ -76,6 +89,7 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
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 } }));
|
||||
@@ -100,8 +114,20 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
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 () => {
|
||||
@@ -113,6 +139,53 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -208,3 +281,32 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,6 +457,8 @@ 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,
|
||||
@@ -605,6 +607,15 @@ 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;
|
||||
@@ -791,16 +802,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 mouseGesture = mouseScrollGestureRef.current;
|
||||
const hasUpwardInputEvidence =
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current;
|
||||
const userScrolledUp = scrolledUp && hasUpwardInputEvidence();
|
||||
|
||||
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
||||
setFollowOutput(true);
|
||||
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
|
||||
} else if (followOutputRef.current && userScrolledUp) {
|
||||
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;
|
||||
@@ -811,8 +822,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
evaluateHistoryStart();
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasUpwardInputEvidence,
|
||||
isJumpSettling,
|
||||
readingAnchor,
|
||||
rearmHistoryStartFromUserIntent,
|
||||
stopFollowingOutputFromUserIntent,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
|
||||
@@ -934,7 +934,6 @@ function RootStack() {
|
||||
<Stack.Screen name="settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/projects/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/projects/[projectId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/providers/[providerId]" />
|
||||
</ThemedStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
|
||||
|
||||
export default function SettingsHostProviderDetailRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
providerId?: string | string[];
|
||||
}>();
|
||||
const serverId = normalizeProjectSettingsRouteId(params.serverId);
|
||||
const providerId = normalizeProjectSettingsRouteId(params.providerId);
|
||||
const view = useMemo(
|
||||
() => ({ kind: "provider" as const, serverId, providerId }),
|
||||
[providerId, serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SettingsScreen view={view} />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { ArchivedWorkspace, ArchivedWorkspaceAgent } from "./internal/group";
|
||||
export {
|
||||
useArchivedWorkspaceSearch,
|
||||
type ArchivedWorkspaceSearch,
|
||||
} from "./internal/use-archived-workspace-search";
|
||||
@@ -0,0 +1,128 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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,6 +39,21 @@ 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;
|
||||
@@ -322,11 +337,12 @@ describe("useFileLink", () => {
|
||||
act(() => {
|
||||
result.current.setWorkspaceRoot("/Users/test/other");
|
||||
});
|
||||
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
await resolveLookupAndDeliver(
|
||||
deferred,
|
||||
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
expect(openedFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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,62 +2,75 @@ 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,
|
||||
): string | null {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
|
||||
): AttachmentPreviewUrl {
|
||||
const [resolved, setResolved] = useState<{
|
||||
key: string;
|
||||
preview: AttachmentPreviewUrl;
|
||||
} | null>(null);
|
||||
const attachmentRef = useRef(attachment);
|
||||
attachmentRef.current = attachment;
|
||||
|
||||
const id = attachment?.id;
|
||||
const storageType = attachment?.storageType;
|
||||
const storageKey = attachment?.storageKey;
|
||||
const mimeType = attachment?.mimeType;
|
||||
const key = attachmentKey(attachment);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let currentUrl: string | null = null;
|
||||
const current = attachmentRef.current;
|
||||
|
||||
activeAttachmentRef.current = current ?? null;
|
||||
if (!current) {
|
||||
setUrl(null);
|
||||
if (!current || key === null) {
|
||||
return;
|
||||
}
|
||||
let disposed = false;
|
||||
let currentUrl: string | null = null;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveAttachmentPreviewUrl(current);
|
||||
const url = await resolveAttachmentPreviewUrl(current);
|
||||
if (disposed) {
|
||||
await releaseAttachmentPreviewUrl({ attachment: current, url: resolved });
|
||||
await releaseAttachmentPreviewUrl({ attachment: current, url });
|
||||
return;
|
||||
}
|
||||
currentUrl = resolved;
|
||||
setUrl(resolved);
|
||||
currentUrl = url;
|
||||
setResolved({ key, preview: { status: "ready", url } });
|
||||
} catch (error) {
|
||||
console.error("[attachments] Failed to resolve preview URL", {
|
||||
attachmentId: current.id,
|
||||
error,
|
||||
});
|
||||
if (!disposed) {
|
||||
setUrl(null);
|
||||
setResolved({ key, preview: FAILED });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
const activeAttachment = activeAttachmentRef.current;
|
||||
if (!currentUrl || !activeAttachment) {
|
||||
return;
|
||||
if (currentUrl) {
|
||||
void releaseAttachmentPreviewUrl({ attachment: current, url: currentUrl });
|
||||
}
|
||||
void releaseAttachmentPreviewUrl({
|
||||
attachment: activeAttachment,
|
||||
url: currentUrl,
|
||||
});
|
||||
// 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));
|
||||
};
|
||||
}, [id, storageType, storageKey, mimeType]);
|
||||
}, [key]);
|
||||
|
||||
return url;
|
||||
return resolved !== null && resolved.key === key ? resolved.preview : LOADING;
|
||||
}
|
||||
|
||||
@@ -24,10 +24,12 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { Check, ChevronRight } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
|
||||
import { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation";
|
||||
import { resolveAgentLabel } from "@/agent-presentation";
|
||||
import { useArchivedWorkspaceSearch, type ArchivedWorkspace } from "@/archived-workspaces";
|
||||
import { ArchivedBadge } from "@/components/archived-badge";
|
||||
import { StatusIcon } from "@/components/status-icon";
|
||||
import { useWorkspaceStatus } from "@/workspace-status";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
@@ -35,12 +37,14 @@ 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,
|
||||
@@ -48,18 +52,20 @@ import {
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useAggregatedAgents, type AggregatedAgent } 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 { useHosts } from "@/runtime/host-runtime";
|
||||
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 {
|
||||
useKeyboardShortcutsStore,
|
||||
type CommandCenterScope,
|
||||
} from "@/stores/keyboard-shortcuts-store";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
navigateToWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
@@ -67,8 +73,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,
|
||||
@@ -76,21 +82,23 @@ import {
|
||||
type CommandCenterListMetrics,
|
||||
} from "./list-metrics";
|
||||
import { useCommandCenterContributions } from "./provider";
|
||||
import { filterAndRankWorkspaces } from "./workspace-search";
|
||||
import { createWorkspaceRanker } from "./workspace-search";
|
||||
import {
|
||||
buildContributionSections,
|
||||
filterAndRankBuiltInResults,
|
||||
joinSubtitleParts,
|
||||
PINNED_SECTION_BAND,
|
||||
projectCommandCenterRows,
|
||||
type CommandCenterAgentResult,
|
||||
type CommandCenterFileResult,
|
||||
type CommandCenterListRow,
|
||||
type CommandCenterResult,
|
||||
type CommandCenterResultSection,
|
||||
type CommandCenterSearchFields,
|
||||
type CommandCenterWorkspaceAgent,
|
||||
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";
|
||||
|
||||
@@ -100,23 +108,16 @@ 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();
|
||||
@@ -124,28 +125,73 @@ function createWorkspaceResultAction(serverId: string, workspaceId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function createAgentResultAction(serverId: string, agentId: string) {
|
||||
return () => {
|
||||
function createAgentResultAction(serverId: string) {
|
||||
return (agentId: string) => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId, agentId });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 useBuiltInRows(open: boolean): {
|
||||
workspaces: CommandCenterWorkspaceResult[];
|
||||
agents: CommandCenterAgentResult[];
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const previousRows = useRef(
|
||||
new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>(),
|
||||
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(),
|
||||
);
|
||||
const { agents } = useAggregatedAgents({ demand: open });
|
||||
const { projects } = useProjects({ enabled: open });
|
||||
const showHost = useHosts().length > 1;
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
const iconTargets = useMemo(
|
||||
() =>
|
||||
open
|
||||
@@ -163,24 +209,26 @@ function useBuiltInRows(open: boolean): {
|
||||
);
|
||||
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
|
||||
|
||||
return useMemo(() => {
|
||||
const active = useMemo(() => {
|
||||
if (!open) {
|
||||
previousRows.current.clear();
|
||||
return { workspaces: [], agents: [] };
|
||||
return [];
|
||||
}
|
||||
// Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged
|
||||
// Workspace 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, CommandCenterAgentResult | CommandCenterWorkspaceResult>();
|
||||
const preserve = <T extends CommandCenterAgentResult | CommandCenterWorkspaceResult>(
|
||||
row: T,
|
||||
): T => {
|
||||
const nextRows = new Map<string, CommandCenterWorkspaceResult>();
|
||||
const preserve = (row: CommandCenterWorkspaceResult): CommandCenterWorkspaceResult => {
|
||||
const previous = previousRows.current.get(row.id);
|
||||
if (previous) row.run = previous.run;
|
||||
const stable = previous && equal(previous, row) ? (previous as T) : row;
|
||||
if (previous) {
|
||||
row.run = previous.run;
|
||||
row.openAgent = previous.openAgent;
|
||||
}
|
||||
const stable = previous && equal(previous, row) ? previous : row;
|
||||
nextRows.set(row.id, stable);
|
||||
return stable;
|
||||
};
|
||||
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
|
||||
const agentsByWorkspace = groupWorkspaceAgents(agents);
|
||||
const rows: CommandCenterWorkspaceResult[] = [];
|
||||
for (const project of projects) {
|
||||
const workspaceProject = {
|
||||
viewKey: project.viewKey,
|
||||
@@ -190,10 +238,11 @@ function useBuiltInRows(open: boolean): {
|
||||
for (const host of project.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
if (workspace.archivingAt) continue;
|
||||
allWorkspaces.push(
|
||||
const key = `${host.serverId}:${workspace.id}`;
|
||||
rows.push(
|
||||
preserve({
|
||||
kind: "workspace",
|
||||
id: `workspace:${host.serverId}:${workspace.id}`,
|
||||
id: `workspace:${key}`,
|
||||
title: workspace.title ?? workspace.name,
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? host.serverName : null,
|
||||
@@ -205,66 +254,93 @@ function useBuiltInRows(open: boolean): {
|
||||
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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
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),
|
||||
});
|
||||
});
|
||||
previousRows.current = nextRows;
|
||||
return { workspaces: allWorkspaces, agents: agentRows };
|
||||
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
|
||||
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)
|
||||
: 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,
|
||||
},
|
||||
];
|
||||
});
|
||||
}, [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,
|
||||
};
|
||||
}
|
||||
|
||||
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]);
|
||||
/**
|
||||
* 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),
|
||||
);
|
||||
}
|
||||
|
||||
interface CommandCenterState {
|
||||
open: boolean;
|
||||
scope: CommandCenterScope;
|
||||
clearScope(): void;
|
||||
/** The tabs on offer, in the order Tab walks them. */
|
||||
tabs: readonly CommandCenterTab[];
|
||||
tab: CommandCenterTab;
|
||||
selectTab(tab: CommandCenterTab): void;
|
||||
query: string;
|
||||
setQuery(query: string): void;
|
||||
activeId: string | null;
|
||||
@@ -275,7 +351,7 @@ interface CommandCenterState {
|
||||
fileSearchError: string | null;
|
||||
close(): void;
|
||||
select(result: CommandCenterResult): void;
|
||||
key(key: string): boolean;
|
||||
key(event: { key: string; shiftKey?: boolean }): boolean;
|
||||
}
|
||||
|
||||
function useCommandCenterState(
|
||||
@@ -285,62 +361,95 @@ function useCommandCenterState(
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
|
||||
const scope = useKeyboardShortcutsStore((state) => state.commandCenterScope);
|
||||
const requestedTab = useKeyboardShortcutsStore((state) => state.commandCenterTab);
|
||||
const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||
const setScope = useKeyboardShortcutsStore((state) => state.setCommandCenterScope);
|
||||
const setTab = useKeyboardShortcutsStore((state) => state.setCommandCenterTab);
|
||||
const snapshot = useCommandCenterContributions();
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const previousOpenRef = useRef(open);
|
||||
const { query, activeId } = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
|
||||
const builtInSections = useBuiltInSections(open, query);
|
||||
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 {
|
||||
entries: fileSearchEntries,
|
||||
loading: fileSearchLoading,
|
||||
error: fileSearchError,
|
||||
openFile,
|
||||
} = useWorkspaceFileSearch({
|
||||
enabled: open && (scope === "files" || Boolean(query.trim())),
|
||||
enabled:
|
||||
open &&
|
||||
tabs.includes("files") &&
|
||||
(tab === "files" || (tab === "all" && Boolean(query.trim()))),
|
||||
query,
|
||||
});
|
||||
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 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 projection = useMemo(
|
||||
() =>
|
||||
projectCommandCenterRows(
|
||||
scope === "files"
|
||||
? fileSections
|
||||
: [...contributionSections, ...fileSections, ...builtInSections],
|
||||
listMetrics,
|
||||
),
|
||||
[builtInSections, contributionSections, fileSections, listMetrics, scope],
|
||||
() => projectCommandCenterRows(sections, listMetrics),
|
||||
[listMetrics, sections],
|
||||
);
|
||||
const loading =
|
||||
fileSearchLoading || (listsWorkspaces && (archivedSearch.pending || workspaceRows.loading));
|
||||
useLayoutEffect(() => {
|
||||
interaction.update({ open, scope, query, projection, loading: fileSearchLoading });
|
||||
}, [interaction, open, scope, query, projection, fileSearchLoading]);
|
||||
interaction.update({ open, tab, query, projection, loading });
|
||||
}, [interaction, open, tab, query, projection, loading]);
|
||||
|
||||
const close = useCallback(() => setOpen(false), [setOpen]);
|
||||
const select = useCallback(
|
||||
@@ -350,16 +459,29 @@ 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(
|
||||
(pressed: string): boolean => {
|
||||
(event: { key: string; shiftKey?: boolean }): boolean => {
|
||||
if (!open) return false;
|
||||
const pressed = event.key;
|
||||
const results = projection.selectableResults;
|
||||
if (pressed === "Escape") {
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Backspace" && !query.trim() && scope) {
|
||||
setScope(null);
|
||||
if (pressed === "Tab") {
|
||||
setTab(rotateCommandCenterTab(tab, event.shiftKey ? "previous" : "next", tabs));
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Backspace" && !query.trim() && tab !== "all") {
|
||||
setTab("all");
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Enter") {
|
||||
@@ -377,9 +499,10 @@ function useCommandCenterState(
|
||||
projection.selectableResults,
|
||||
query,
|
||||
activeId,
|
||||
scope,
|
||||
tab,
|
||||
tabs,
|
||||
select,
|
||||
setScope,
|
||||
setTab,
|
||||
interaction,
|
||||
],
|
||||
);
|
||||
@@ -405,8 +528,9 @@ function useCommandCenterState(
|
||||
|
||||
return {
|
||||
open,
|
||||
scope,
|
||||
clearScope: () => setScope(null),
|
||||
tabs,
|
||||
tab,
|
||||
selectTab,
|
||||
query,
|
||||
setQuery: interaction.setQuery,
|
||||
activeId,
|
||||
@@ -536,55 +660,38 @@ function SubtitleWithTimestamp({
|
||||
);
|
||||
}
|
||||
|
||||
const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function AgentResultContent({
|
||||
result,
|
||||
/**
|
||||
* 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,
|
||||
query,
|
||||
backdrop,
|
||||
}: {
|
||||
result: CommandCenterAgentResult;
|
||||
agent: CommandCenterWorkspaceAgent;
|
||||
serverId: string;
|
||||
query: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
const agent = result.agent;
|
||||
const descriptor = useAgentDescriptor(
|
||||
{ kind: "agent", agentId: agent.id },
|
||||
{ serverId: agent.serverId },
|
||||
);
|
||||
const Icon = useMemo(
|
||||
() => withUnistyles(descriptor.icon, providerIconMapping),
|
||||
[descriptor.icon],
|
||||
);
|
||||
const ProviderIcon = useProviderIcon(agent.provider, serverId);
|
||||
const Icon = useMemo(() => withUnistyles(ProviderIcon, providerIconMapping), [ProviderIcon]);
|
||||
return (
|
||||
<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 style={styles.agentLine} testID="command-center-workspace-agent">
|
||||
<View style={styles.agentLineIcon}>
|
||||
<Icon size={12} />
|
||||
</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,
|
||||
@@ -595,24 +702,27 @@ function WorkspaceResultIcon({
|
||||
const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId);
|
||||
return (
|
||||
<StatusIcon statusBucket={statusBucket} size={16} backdrop={backdrop}>
|
||||
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
|
||||
<WorkspaceProjectIcon project={result.project} />
|
||||
</View>
|
||||
<DecorativeProjectIcon project={result.project} />
|
||||
</StatusIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
|
||||
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
|
||||
/**
|
||||
* 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"] }) {
|
||||
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
|
||||
return (
|
||||
<ProjectIconView
|
||||
iconDataUri={project.iconDataUri}
|
||||
initial={initial}
|
||||
projectViewKey={project.viewKey}
|
||||
size={16}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
/>
|
||||
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
|
||||
<ProjectIconView
|
||||
iconDataUri={project.iconDataUri}
|
||||
initial={initial}
|
||||
projectViewKey={project.viewKey}
|
||||
size={16}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -650,17 +760,18 @@ 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}>
|
||||
<WorkspaceResultIcon result={result} backdrop={backdrop} />
|
||||
{/* An archived workspace runs nothing, so it has no status to show. */}
|
||||
{result.archived ? (
|
||||
<DecorativeProjectIcon project={result.project} />
|
||||
) : (
|
||||
<WorkspaceResultIcon result={result} backdrop={backdrop} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
@@ -675,8 +786,16 @@ 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>
|
||||
);
|
||||
}
|
||||
@@ -761,9 +880,24 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
|
||||
}
|
||||
|
||||
// Escape arrives without asking; it is registered for `agent.interrupt` anyway.
|
||||
// Backspace stays off deliberately — the palette pops its scope with it on web,
|
||||
// Backspace stays off deliberately — on web it returns a tab to All when the field is empty,
|
||||
// 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();
|
||||
@@ -875,22 +1009,42 @@ export function CommandCenter() {
|
||||
: {}),
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const submit = useCallback(() => state.key("Enter"), [state]);
|
||||
const submit = useCallback(() => state.key({ key: "Enter" }), [state]);
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (!state.key(event.key)) return false;
|
||||
if (!state.key(event)) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
[state],
|
||||
);
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
|
||||
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown", "Tab"],
|
||||
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
|
||||
@@ -910,32 +1064,28 @@ export function CommandCenter() {
|
||||
accessible={false}
|
||||
>
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<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 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>
|
||||
<View style={styles.tabBar}>{tabs}</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</OverlayLayerProvider>
|
||||
@@ -949,30 +1099,26 @@ 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, 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 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>
|
||||
<View style={styles.tabBar}>{tabs}</View>
|
||||
{fileSearchError}
|
||||
<FlatList ref={listRef} {...commonListProps} />
|
||||
</View>
|
||||
@@ -998,25 +1144,6 @@ 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,
|
||||
@@ -1043,11 +1170,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
// 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: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
paddingTop: COMMAND_CENTER_SECTION_GAP,
|
||||
},
|
||||
input: {
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -1058,25 +1186,6 @@ 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" },
|
||||
@@ -1129,6 +1238,14 @@ 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 { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
|
||||
import type { CommandCenterTab } from "./tabs";
|
||||
|
||||
function results(ids = ["a", "b", "c", "d"]) {
|
||||
return projectCommandCenterRows(
|
||||
@@ -21,17 +21,17 @@ function results(ids = ["a", "b", "c", "d"]) {
|
||||
})),
|
||||
},
|
||||
],
|
||||
{ singleLineRow: 40, twoLineRow: 60, sectionTitle: 20, sectionDivider: 10 },
|
||||
{ singleLineRow: 40, twoLineRow: 60, threeLineRow: 80, sectionTitle: 20, sectionDivider: 10 },
|
||||
);
|
||||
}
|
||||
|
||||
function setup(options: { scope?: CommandCenterScope; measured?: boolean } = {}) {
|
||||
function setup(options: { tab?: CommandCenterTab; 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,
|
||||
scope: options.scope ?? null,
|
||||
tab: options.tab ?? "all",
|
||||
query: model.getSnapshot().query,
|
||||
projection: results(ids),
|
||||
loading,
|
||||
@@ -53,6 +53,45 @@ 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);
|
||||
@@ -120,7 +159,7 @@ describe("Command Center interaction", () => {
|
||||
});
|
||||
|
||||
it("waits for scoped search results instead of revealing the previous query's rows", () => {
|
||||
const { model, offsets, publish } = setup({ scope: "files" });
|
||||
const { model, offsets, publish } = setup({ tab: "files" });
|
||||
model.scrolled(100);
|
||||
model.setQuery("new");
|
||||
publish(["a", "b"], true);
|
||||
@@ -132,7 +171,7 @@ describe("Command Center interaction", () => {
|
||||
});
|
||||
|
||||
it("manual interaction cancels a reveal awaiting search results", () => {
|
||||
const { model, offsets, publish } = setup({ scope: "files" });
|
||||
const { model, offsets, publish } = setup({ tab: "files" });
|
||||
model.setQuery("new");
|
||||
publish([], true);
|
||||
model.beginManualScroll();
|
||||
@@ -153,13 +192,29 @@ describe("Command Center interaction", () => {
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("scope changes reset selection and request a reveal", () => {
|
||||
it("tab changes select the first result and return the list to the top", () => {
|
||||
const { model, offsets } = setup();
|
||||
model.move("next");
|
||||
model.move("next");
|
||||
model.move("next");
|
||||
model.scrolled(100);
|
||||
model.update({ open: true, scope: "files", query: "", projection: results(), loading: false });
|
||||
model.update({ open: true, tab: "files", query: "", projection: results(), loading: false });
|
||||
expect(model.getSnapshot().activeId).toBe("a");
|
||||
expect(offsets).toEqual([20, 20]);
|
||||
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" });
|
||||
});
|
||||
|
||||
it("close discards the old query, metrics, and deferred reveal", () => {
|
||||
@@ -167,7 +222,7 @@ describe("Command Center interaction", () => {
|
||||
model.setQuery("old");
|
||||
publish();
|
||||
model.move("previous");
|
||||
model.update({ open: false, scope: null, query: "old", projection: results(), loading: false });
|
||||
model.update({ open: false, tab: "all", query: "old", projection: results(), loading: false });
|
||||
model.layout(80);
|
||||
expect(model.getSnapshot()).toEqual({ query: "", activeId: null });
|
||||
expect(offsets).toEqual([]);
|
||||
|
||||
@@ -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,21 +12,28 @@ interface Snapshot {
|
||||
|
||||
interface ResultsInput {
|
||||
open: boolean;
|
||||
scope: CommandCenterScope;
|
||||
tab: CommandCenterTab;
|
||||
query: string;
|
||||
projection: CommandCenterListProjection;
|
||||
/** A source of this query's and tab's results is still loading. */
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/** Selection persists; permission to move the viewport lasts for one user command. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function createCommandCenterInteraction(scrollToOffset: (offset: number) => void) {
|
||||
let snapshot: Snapshot = { query: "", activeId: null };
|
||||
const listeners = new Set<() => void>();
|
||||
let open = false;
|
||||
let scope: CommandCenterScope = null;
|
||||
let tab: CommandCenterTab = "all";
|
||||
let projection: CommandCenterListProjection | null = null;
|
||||
let loading = false;
|
||||
let revealPending = false;
|
||||
let followingFirst = true;
|
||||
let offset = 0;
|
||||
let visibleLength = 0;
|
||||
|
||||
@@ -38,8 +45,8 @@ export function createCommandCenterInteraction(scrollToOffset: (offset: number)
|
||||
|
||||
function reveal() {
|
||||
if (!open || !revealPending || !projection || visibleLength <= 0) return;
|
||||
// Scoped file search keeps its previous entries while the next query is loading.
|
||||
if (scope === "files" && loading) return;
|
||||
// The Files tab keeps its previous entries while the next query is loading.
|
||||
if (tab === "files" && loading) return;
|
||||
if (!snapshot.activeId) {
|
||||
if (!loading) revealPending = false;
|
||||
return;
|
||||
@@ -67,9 +74,10 @@ export function createCommandCenterInteraction(scrollToOffset: (offset: number)
|
||||
};
|
||||
},
|
||||
update(input: ResultsInput) {
|
||||
const resetSelection = input.open && (!open || scope !== input.scope);
|
||||
const tabChanged = open && input.open && tab !== input.tab;
|
||||
const resetSelection = input.open && (!open || tabChanged);
|
||||
open = input.open;
|
||||
scope = input.scope;
|
||||
tab = input.tab;
|
||||
if (!open) {
|
||||
projection = null;
|
||||
revealPending = false;
|
||||
@@ -80,33 +88,44 @@ 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 (scope !== "files" || !loading) {
|
||||
if (tab !== "files" || !loading) {
|
||||
publish({
|
||||
...snapshot,
|
||||
activeId: preserveActiveResultId(snapshot.activeId, projection.selectableResults),
|
||||
activeId: followingFirst
|
||||
? (projection.selectableResults[0]?.id ?? null)
|
||||
: 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 ||
|
||||
(scope === "files" && loading) ||
|
||||
(tab === "files" && loading) ||
|
||||
projection.selectableResults.length === 0
|
||||
)
|
||||
return false;
|
||||
followingFirst = false;
|
||||
publish({
|
||||
...snapshot,
|
||||
activeId: moveActiveResultId(snapshot.activeId, projection.selectableResults, direction),
|
||||
|
||||
@@ -16,6 +16,8 @@ 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;
|
||||
|
||||
@@ -26,6 +28,8 @@ 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. */
|
||||
@@ -37,6 +41,7 @@ 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,
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { CommandCenterContribution } from "./contributions";
|
||||
import { COMMAND_CENTER_LIST_METRICS } from "./list-metrics";
|
||||
import {
|
||||
buildContributionSections,
|
||||
filterAndRankBuiltInResults,
|
||||
joinSubtitleParts,
|
||||
moveActiveResultId,
|
||||
PINNED_SECTION_BAND,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
projectCommandCenterRows,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
import { createWorkspaceRanker } from "./workspace-search";
|
||||
|
||||
function contribution(input: {
|
||||
id: string;
|
||||
@@ -118,7 +118,12 @@ function workspace(
|
||||
project: { viewKey: "project", name: "project", iconDataUri: null },
|
||||
changeRequestNumber: null,
|
||||
activityAt,
|
||||
agents: [],
|
||||
matchedAgent: null,
|
||||
archived: false,
|
||||
background: false,
|
||||
run: () => undefined,
|
||||
openAgent: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +189,40 @@ 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");
|
||||
@@ -486,25 +525,9 @@ describe("Command Center query tokenization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
]);
|
||||
});
|
||||
describe("workspace ranking", () => {
|
||||
const rank = (rows: readonly CommandCenterWorkspaceResult[], query: string) =>
|
||||
createWorkspaceRanker()(rows, query).map((row) => row.id);
|
||||
|
||||
it("puts the best match first even when it sorts last alphabetically", () => {
|
||||
const rows = [
|
||||
@@ -514,22 +537,7 @@ describe("filterAndRankBuiltInResults", () => {
|
||||
];
|
||||
|
||||
// Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design.
|
||||
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",
|
||||
]);
|
||||
expect(rank(rows, "lab des")).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("orders rows in the same match tier by recency, not by where the word sits", () => {
|
||||
@@ -539,10 +547,7 @@ describe("filterAndRankBuiltInResults", () => {
|
||||
workspace("newer", "design the cli interface to expose usage data", "host", minutesAgo(5)),
|
||||
];
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"newer",
|
||||
"older",
|
||||
]);
|
||||
expect(rank(rows, "usage")).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("keeps a better match tier above a more recent weaker match", () => {
|
||||
@@ -551,16 +556,13 @@ describe("filterAndRankBuiltInResults", () => {
|
||||
workspace("whole-word", "Usage hover card", "host", minutesAgo(600)),
|
||||
];
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "usage", visibleOnly).map((row) => row.id)).toEqual([
|
||||
"whole-word",
|
||||
"subsequence",
|
||||
]);
|
||||
expect(rank(rows, "usage")).toEqual(["whole-word", "subsequence"]);
|
||||
});
|
||||
|
||||
it("drops a row when one token matches nothing", () => {
|
||||
const rows = [workspace("a", "Label as Design")];
|
||||
|
||||
expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly)).toEqual([]);
|
||||
expect(rank(rows, "lab zzz")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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. */
|
||||
@@ -50,6 +53,13 @@ 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;
|
||||
@@ -60,18 +70,19 @@ 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;
|
||||
}
|
||||
|
||||
export interface CommandCenterAgentResult {
|
||||
kind: "agent";
|
||||
id: string;
|
||||
agent: AggregatedAgent;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
activityAt: Date | null;
|
||||
run(): void;
|
||||
/** Opens the workspace on this agent's tab. */
|
||||
openAgent(agentId: string): void;
|
||||
}
|
||||
|
||||
export interface CommandCenterFileResult {
|
||||
@@ -94,13 +105,12 @@ export interface CommandCenterContributionResult {
|
||||
|
||||
export type CommandCenterResult =
|
||||
| CommandCenterWorkspaceResult
|
||||
| CommandCenterAgentResult
|
||||
| CommandCenterFileResult
|
||||
| CommandCenterContributionResult;
|
||||
|
||||
export interface CommandCenterResultSection {
|
||||
id: string;
|
||||
/** CONTRIBUTION_SECTION_BAND or PINNED_SECTION_BAND. Compared before anything else. */
|
||||
/** LEADING, CONTRIBUTION or PINNED_SECTION_BAND. Compared before anything else. */
|
||||
band: number;
|
||||
rank: number;
|
||||
title?: string;
|
||||
@@ -162,6 +172,14 @@ 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(" · ");
|
||||
@@ -181,12 +199,18 @@ function contributionSearchFields(
|
||||
return { visible, hidden: contribution.keywords };
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** 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;
|
||||
const presentation = result.contribution.presentation;
|
||||
return presentation.kind === "action" && Boolean(presentation.subtitle);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,10 +235,7 @@ function resultScore(result: CommandCenterResult): CommandCenterScore | undefine
|
||||
return result.kind === "contribution" ? result.score : undefined;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** A built-in row that orders by how recently it saw activity. */
|
||||
export interface CommandCenterRecentRow {
|
||||
activityAt: Date | null;
|
||||
}
|
||||
@@ -235,44 +256,20 @@ export function compareBuiltInScores(left: CommandCenterScore, right: CommandCen
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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) continue;
|
||||
if (contribution.visibility === "query" && !hasQuery && !options.includeQueryOnly) continue;
|
||||
let score: CommandCenterScore | undefined;
|
||||
if (hasQuery) {
|
||||
// The score is the filter: null means no token found a home, so the row is out.
|
||||
@@ -351,7 +348,7 @@ export function projectCommandCenterRows(
|
||||
offset += sectionHeight;
|
||||
}
|
||||
for (const result of section.results) {
|
||||
const height = hasSubtitleLine(result) ? metrics.twoLineRow : metrics.singleLineRow;
|
||||
const height = resultHeight(result, metrics);
|
||||
offsets.push(offset);
|
||||
rowIndexByResultId.set(result.id, rows.length);
|
||||
rows.push({ kind: "result", key: result.id, result, height });
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
import {
|
||||
buildContributionSections,
|
||||
LEADING_SECTION_BAND,
|
||||
PINNED_SECTION_BAND,
|
||||
type CommandCenterFileResult,
|
||||
type CommandCenterResultSection,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
import type { CommandCenterTab } from "./tabs";
|
||||
import type { WorkspaceRanker } from "./workspace-search";
|
||||
|
||||
/**
|
||||
* How many workspaces All leads with before anything is typed. Background workspaces are left
|
||||
* out, as the sidebar leaves them out by default.
|
||||
*/
|
||||
const RECENT_WORKSPACE_LIMIT = 3;
|
||||
|
||||
export interface CommandCenterSectionTitles {
|
||||
recentWorkspaces: string;
|
||||
workspaces: string;
|
||||
files: string;
|
||||
}
|
||||
|
||||
export interface CommandCenterSectionsInput {
|
||||
tab: CommandCenterTab;
|
||||
query: string;
|
||||
rankWorkspaces: WorkspaceRanker;
|
||||
workspaces: readonly CommandCenterWorkspaceResult[];
|
||||
/** Archived workspaces the host found for the query. Empty without a query. */
|
||||
archivedWorkspaces: readonly CommandCenterWorkspaceResult[];
|
||||
/**
|
||||
* The host matched the archived workspaces for this exact query. Its search forgives typos,
|
||||
* so its matches stay even when the client cannot score them. Results from the previous
|
||||
* query must match on their own.
|
||||
*/
|
||||
archivedMatchedByHost: boolean;
|
||||
/**
|
||||
* The sidebar's Show background preference. Without a query, Workspaces lists background
|
||||
* workspaces only when the sidebar does; a query finds them either way.
|
||||
*/
|
||||
showBackground: boolean;
|
||||
/** The open workspace's result ID. Recent workspaces leaves it out: the user is already there. */
|
||||
currentWorkspaceId: string | null;
|
||||
contributions: readonly CommandCenterContribution[];
|
||||
files: readonly CommandCenterFileResult[];
|
||||
titles: CommandCenterSectionTitles;
|
||||
}
|
||||
|
||||
function recentWorkspacesSection(input: CommandCenterSectionsInput): CommandCenterResultSection {
|
||||
const recent = input
|
||||
.rankWorkspaces(input.workspaces, "")
|
||||
.filter((workspace) => workspace.id !== input.currentWorkspaceId && !workspace.background)
|
||||
.slice(0, RECENT_WORKSPACE_LIMIT);
|
||||
return {
|
||||
id: "recent-workspaces",
|
||||
band: LEADING_SECTION_BAND,
|
||||
rank: 0,
|
||||
title: input.titles.recentWorkspaces,
|
||||
results: recent,
|
||||
};
|
||||
}
|
||||
|
||||
/** Active matches first; archived ones are appended, so active rows never move when they land. */
|
||||
function workspacesSection(input: CommandCenterSectionsInput): CommandCenterResultSection {
|
||||
const listed =
|
||||
input.query.trim() || input.showBackground
|
||||
? input.workspaces
|
||||
: input.workspaces.filter((workspace) => !workspace.background);
|
||||
const active = input.rankWorkspaces(listed, input.query);
|
||||
const archived = input.query.trim()
|
||||
? input.rankWorkspaces(input.archivedWorkspaces, input.query, {
|
||||
keepUnmatched: input.archivedMatchedByHost,
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
id: "workspaces",
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: input.titles.workspaces,
|
||||
results: [...active, ...archived],
|
||||
};
|
||||
}
|
||||
|
||||
function filesSection(input: CommandCenterSectionsInput): CommandCenterResultSection {
|
||||
// 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: input.titles.files,
|
||||
results: input.files,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections a tab shows. All mixes every kind and leads with recent workspaces until
|
||||
* something is typed; every other tab lists everything of its one kind.
|
||||
*/
|
||||
export function buildCommandCenterSections(
|
||||
input: CommandCenterSectionsInput,
|
||||
): CommandCenterResultSection[] {
|
||||
switch (input.tab) {
|
||||
case "all": {
|
||||
const actions = buildContributionSections(input.contributions, input.query);
|
||||
if (!input.query.trim()) return [recentWorkspacesSection(input), ...actions];
|
||||
return [...actions, workspacesSection(input), filesSection(input)];
|
||||
}
|
||||
case "workspaces":
|
||||
return [workspacesSection(input)];
|
||||
case "files":
|
||||
return [filesSection(input)];
|
||||
case "actions":
|
||||
return buildContributionSections(input.contributions, input.query, {
|
||||
includeQueryOnly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
availableCommandCenterTabs,
|
||||
resolveCommandCenterTab,
|
||||
rotateCommandCenterTab,
|
||||
type CommandCenterTab,
|
||||
} from "./tabs";
|
||||
|
||||
const IN_WORKSPACE = availableCommandCenterTabs({ hasWorkspace: true });
|
||||
const NO_WORKSPACE = availableCommandCenterTabs({ hasWorkspace: false });
|
||||
|
||||
function walk(available: readonly CommandCenterTab[], direction: "next" | "previous") {
|
||||
const walked: CommandCenterTab[] = ["all"];
|
||||
for (let step = 0; step < available.length; step += 1) {
|
||||
walked.push(rotateCommandCenterTab(walked[walked.length - 1], direction, available));
|
||||
}
|
||||
return walked;
|
||||
}
|
||||
|
||||
describe("command center tabs", () => {
|
||||
it("walks All, Workspaces, Files, Actions in a workspace and wraps", () => {
|
||||
expect(walk(IN_WORKSPACE, "next")).toEqual(["all", "workspaces", "files", "actions", "all"]);
|
||||
expect(walk(IN_WORKSPACE, "previous")).toEqual([
|
||||
"all",
|
||||
"actions",
|
||||
"files",
|
||||
"workspaces",
|
||||
"all",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers no Files tab outside a workspace, and Tab skips it", () => {
|
||||
expect(NO_WORKSPACE).toEqual(["all", "workspaces", "actions"]);
|
||||
expect(walk(NO_WORKSPACE, "next")).toEqual(["all", "workspaces", "actions", "all"]);
|
||||
expect(walk(NO_WORKSPACE, "previous")).toEqual(["all", "actions", "workspaces", "all"]);
|
||||
});
|
||||
|
||||
it("opens a requested tab only when it is on offer", () => {
|
||||
expect(resolveCommandCenterTab("files", IN_WORKSPACE)).toBe("files");
|
||||
expect(resolveCommandCenterTab("files", NO_WORKSPACE)).toBe("all");
|
||||
expect(resolveCommandCenterTab("actions", NO_WORKSPACE)).toBe("actions");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The command center's tabs, in the order Tab walks them. All shows every kind of result; each
|
||||
* other tab shows one kind.
|
||||
*/
|
||||
const COMMAND_CENTER_TABS = ["all", "workspaces", "files", "actions"] as const;
|
||||
|
||||
export type CommandCenterTab = (typeof COMMAND_CENTER_TABS)[number];
|
||||
|
||||
/**
|
||||
* The tabs on offer, in the order Tab walks them. Files searches the open workspace, so it is
|
||||
* offered only when a workspace is open; without one, All lists no files either.
|
||||
*/
|
||||
export function availableCommandCenterTabs(context: {
|
||||
hasWorkspace: boolean;
|
||||
}): readonly CommandCenterTab[] {
|
||||
return context.hasWorkspace
|
||||
? COMMAND_CENTER_TABS
|
||||
: COMMAND_CENTER_TABS.filter((tab) => tab !== "files");
|
||||
}
|
||||
|
||||
/** The tab to show for a requested one: itself when on offer, otherwise All. */
|
||||
export function resolveCommandCenterTab(
|
||||
requested: CommandCenterTab,
|
||||
available: readonly CommandCenterTab[],
|
||||
): CommandCenterTab {
|
||||
return available.includes(requested) ? requested : "all";
|
||||
}
|
||||
|
||||
/** The available tab after (or before) `tab`, wrapping at either end. */
|
||||
export function rotateCommandCenterTab(
|
||||
tab: CommandCenterTab,
|
||||
direction: "next" | "previous",
|
||||
available: readonly CommandCenterTab[],
|
||||
): CommandCenterTab {
|
||||
const index = available.indexOf(tab);
|
||||
const delta = direction === "next" ? 1 : -1;
|
||||
const count = available.length;
|
||||
return available[(index + delta + count) % count];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CommandCenterWorkspaceResult } from "./results";
|
||||
import { filterAndRankWorkspaces } from "./workspace-search";
|
||||
import type { CommandCenterWorkspaceAgent, CommandCenterWorkspaceResult } from "./results";
|
||||
import { createWorkspaceRanker } from "./workspace-search";
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
@@ -8,6 +8,8 @@ function workspace(input: {
|
||||
subtitle?: string;
|
||||
changeRequestNumber?: number | null;
|
||||
activityAt?: Date | null;
|
||||
agents?: readonly CommandCenterWorkspaceAgent[];
|
||||
opened?: string[];
|
||||
}): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
@@ -19,15 +21,24 @@ function workspace(input: {
|
||||
project: { viewKey: "project", name: "project", iconDataUri: null },
|
||||
changeRequestNumber: input.changeRequestNumber ?? null,
|
||||
activityAt: input.activityAt ?? null,
|
||||
run: () => undefined,
|
||||
agents: input.agents ?? [],
|
||||
matchedAgent: null,
|
||||
archived: false,
|
||||
background: false,
|
||||
run: () => input.opened?.push("workspace"),
|
||||
openAgent: (agentId) => input.opened?.push(agentId),
|
||||
};
|
||||
}
|
||||
|
||||
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
|
||||
return filterAndRankWorkspaces(rows, query).map((row) => row.id);
|
||||
function agent(id: string, title: string): CommandCenterWorkspaceAgent {
|
||||
return { id, title, provider: "mock" };
|
||||
}
|
||||
|
||||
describe("filterAndRankWorkspaces", () => {
|
||||
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
|
||||
return createWorkspaceRanker()(rows, query).map((row) => row.id);
|
||||
}
|
||||
|
||||
describe("workspace ranking", () => {
|
||||
const subject = workspace({
|
||||
id: "payments",
|
||||
title: "Payments refactor",
|
||||
@@ -96,4 +107,214 @@ describe("filterAndRankWorkspaces", () => {
|
||||
});
|
||||
expect(ids([older, newer], "usage")).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("lists every workspace most recent first when the query is empty", () => {
|
||||
const rows = Array.from({ length: 8 }, (_, index) =>
|
||||
workspace({ id: `w${index}`, activityAt: new Date(2026, 0, index + 1) }),
|
||||
);
|
||||
expect(ids(rows, "")).toEqual(["w7", "w6", "w5", "w4", "w3", "w2", "w1", "w0"]);
|
||||
});
|
||||
|
||||
it("matches a workspace through one of its agents and names that agent", () => {
|
||||
const receipts = agent("agent-receipts", "Add receipt emails");
|
||||
const row = workspace({
|
||||
id: "onboarding",
|
||||
title: "Onboarding flow",
|
||||
agents: [agent("agent-welcome", "Polish welcome screen"), receipts],
|
||||
});
|
||||
const [ranked] = createWorkspaceRanker()([row], "receipt");
|
||||
expect(ranked.id).toBe("onboarding");
|
||||
expect(ranked.matchedAgent).toEqual(receipts);
|
||||
});
|
||||
|
||||
it("names no agent when the workspace's own text matched", () => {
|
||||
const row = workspace({
|
||||
id: "payments",
|
||||
title: "Payments refactor",
|
||||
agents: [agent("agent-payments", "Payments retries")],
|
||||
});
|
||||
const [ranked] = createWorkspaceRanker()([row], "payments");
|
||||
expect(ranked.matchedAgent).toBeNull();
|
||||
});
|
||||
|
||||
it("ranks a workspace's own match above an equally good match through an agent", () => {
|
||||
const viaAgent = workspace({
|
||||
id: "via-agent",
|
||||
title: "Onboarding flow",
|
||||
activityAt: new Date("2026-10-17T00:00:00Z"),
|
||||
agents: [agent("a", "Receipt emails")],
|
||||
});
|
||||
const own = workspace({
|
||||
id: "own",
|
||||
title: "Receipt redesign",
|
||||
activityAt: new Date("2026-10-01T00:00:00Z"),
|
||||
});
|
||||
expect(ids([viaAgent, own], "receipt")).toEqual(["own", "via-agent"]);
|
||||
});
|
||||
|
||||
it("picks the best-matching agent for the line", () => {
|
||||
const row = workspace({
|
||||
id: "w",
|
||||
title: "Onboarding",
|
||||
agents: [agent("loose", "Reconcile cash receipts later"), agent("exact", "receipts")],
|
||||
});
|
||||
const [ranked] = createWorkspaceRanker()([row], "receipts");
|
||||
expect(ranked.matchedAgent?.id).toBe("exact");
|
||||
});
|
||||
|
||||
it("matches a query split between the workspace's text and an agent's title", () => {
|
||||
const login = agent("agent-login", "Fix login retries");
|
||||
const row = workspace({ id: "payments", title: "payments", agents: [login] });
|
||||
const [ranked] = createWorkspaceRanker()([row], "payments login");
|
||||
expect(ranked?.id).toBe("payments");
|
||||
expect(ranked?.matchedAgent).toEqual(login);
|
||||
});
|
||||
|
||||
it("names the agent when it matches better than a loose match on the workspace's text", () => {
|
||||
const opened: string[] = [];
|
||||
const login = agent("agent-login", "Fix login retries");
|
||||
const row = workspace({
|
||||
id: "long-input",
|
||||
title: "Long input",
|
||||
// "login" is a subsequence of "long-input", the weakest kind of match.
|
||||
subtitle: "Local · paseo · feature/long-input",
|
||||
agents: [login],
|
||||
opened,
|
||||
});
|
||||
const [ranked] = createWorkspaceRanker()([row], "login");
|
||||
expect(ranked?.matchedAgent).toEqual(login);
|
||||
ranked?.run();
|
||||
expect(opened).toEqual(["agent-login"]);
|
||||
});
|
||||
|
||||
it("keeps a change-request hit on the workspace even when an agent's title names the number", () => {
|
||||
const opened: string[] = [];
|
||||
const row = workspace({
|
||||
id: "payments",
|
||||
title: "Payments refactor",
|
||||
changeRequestNumber: 42,
|
||||
agents: [agent("agent-review", "Address PR 42 review comments")],
|
||||
opened,
|
||||
});
|
||||
const rank = createWorkspaceRanker();
|
||||
for (const query of ["42", "pr 42", "#42"]) {
|
||||
const [ranked] = rank([row], query);
|
||||
expect(ranked?.matchedAgent).toBeNull();
|
||||
ranked?.run();
|
||||
}
|
||||
expect(opened).toEqual(["workspace", "workspace", "workspace"]);
|
||||
});
|
||||
|
||||
it("names no agent while the workspace's own text matches every keystroke contiguously", () => {
|
||||
const row = workspace({
|
||||
id: "fix-login-bug",
|
||||
title: "fix-login-bug",
|
||||
subtitle: "paseo · fix/login-bug",
|
||||
agents: [agent("agent-login", "Login page fix")],
|
||||
});
|
||||
const rank = createWorkspaceRanker();
|
||||
for (const query of ["l", "lo", "log", "logi", "login"]) {
|
||||
expect(rank([row], query)[0]?.matchedAgent).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("names no agent whose one-word title matches better than the workspace's whole word", () => {
|
||||
const row = workspace({
|
||||
id: "review-queue",
|
||||
title: "Review queue",
|
||||
agents: [agent("agent-review", "Review")],
|
||||
});
|
||||
expect(createWorkspaceRanker()([row], "review")[0]?.matchedAgent).toBeNull();
|
||||
});
|
||||
|
||||
it("ranks a better match through an agent above a looser match on a workspace's own text", () => {
|
||||
const loose = workspace({
|
||||
id: "loose-own",
|
||||
title: "Long input",
|
||||
subtitle: "Local · paseo · feature/long-input",
|
||||
activityAt: new Date("2026-10-17T00:00:00Z"),
|
||||
});
|
||||
const viaAgent = workspace({
|
||||
id: "via-agent",
|
||||
title: "Auth",
|
||||
activityAt: new Date("2026-10-01T00:00:00Z"),
|
||||
agents: [agent("a", "login page")],
|
||||
});
|
||||
expect(ids([loose, viaAgent], "login")).toEqual(["via-agent", "loose-own"]);
|
||||
});
|
||||
|
||||
it("keeps a workspace that also matches on its own name first while typing an agent's title", () => {
|
||||
const ownAndAgent = workspace({
|
||||
id: "oauth-refactor",
|
||||
activityAt: new Date("2026-10-17T00:00:00Z"),
|
||||
agents: [agent("agent-a", "Auth flow fix")],
|
||||
});
|
||||
const agentOnly = workspace({
|
||||
id: "cleanup",
|
||||
activityAt: new Date("2026-10-01T00:00:00Z"),
|
||||
agents: [agent("agent-b", "Auth flow fix")],
|
||||
});
|
||||
for (const query of ["a", "au", "auth", "auth f", "auth fl", "auth flow"]) {
|
||||
expect(ids([ownAndAgent, agentOnly], query)).toEqual(["oauth-refactor", "cleanup"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("ranks a word match on a workspace's name above a newer one that matches only inside a word", () => {
|
||||
const rank = createWorkspaceRanker();
|
||||
const authService = workspace({
|
||||
id: "auth-service",
|
||||
activityAt: new Date("2026-10-01T00:00:00Z"),
|
||||
});
|
||||
const oauthRefactor = workspace({
|
||||
id: "oauth-refactor",
|
||||
activityAt: new Date("2026-10-17T00:00:00Z"),
|
||||
agents: [agent("agent-auth", "Auth flow fix")],
|
||||
});
|
||||
const authRows = rank([oauthRefactor, authService], "auth");
|
||||
expect(authRows.map((row) => row.id)).toEqual(["auth-service", "oauth-refactor"]);
|
||||
// The row that matched through its agent says so.
|
||||
expect(authRows[1]?.matchedAgent?.title).toBe("Auth flow fix");
|
||||
|
||||
const fixLogin = workspace({ id: "fix-login", activityAt: new Date("2026-10-17T00:00:00Z") });
|
||||
const prefixedThing = workspace({
|
||||
id: "prefixed-thing",
|
||||
activityAt: new Date("2026-10-01T00:00:00Z"),
|
||||
agents: [agent("agent-fix", "Fix")],
|
||||
});
|
||||
const fixRows = rank([prefixedThing, fixLogin], "fix");
|
||||
expect(fixRows.map((row) => row.id)).toEqual(["fix-login", "prefixed-thing"]);
|
||||
expect(fixRows[1]?.matchedAgent?.title).toBe("Fix");
|
||||
});
|
||||
|
||||
it("opens the matched agent's tab, and the workspace otherwise", () => {
|
||||
const opened: string[] = [];
|
||||
const row = workspace({
|
||||
id: "w",
|
||||
title: "Onboarding flow",
|
||||
agents: [agent("agent-receipts", "Add receipt emails")],
|
||||
opened,
|
||||
});
|
||||
const rank = createWorkspaceRanker();
|
||||
rank([row], "receipt")[0].run();
|
||||
rank([row], "onboarding")[0].run();
|
||||
expect(opened).toEqual(["agent-receipts", "workspace"]);
|
||||
});
|
||||
|
||||
it("returns the same row object across keystrokes while the matched agent holds", () => {
|
||||
const row = workspace({
|
||||
id: "w",
|
||||
title: "Onboarding flow",
|
||||
agents: [agent("agent-receipts", "Add receipt emails")],
|
||||
});
|
||||
const rank = createWorkspaceRanker();
|
||||
expect(rank([row], "rece")[0]).toBe(rank([row], "receipt")[0]);
|
||||
expect(rank([row], "onboard")[0]).toBe(row);
|
||||
});
|
||||
|
||||
it("keeps rows the host already matched when asked to, without an agent line", () => {
|
||||
const row = workspace({ id: "archived", title: "Billing cleanup" });
|
||||
const rank = createWorkspaceRanker();
|
||||
expect(rank([row], "billign")).toEqual([]);
|
||||
expect(rank([row], "billign", { keepUnmatched: true })).toEqual([row]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { compareMatchClasses, type MatchClass } from "@getpaseo/protocol/search/text-match";
|
||||
import {
|
||||
compareBuiltInScores,
|
||||
compareByRecency,
|
||||
filterAndRankBuiltInResults,
|
||||
scoreSearchFields,
|
||||
searchFieldsMatchClass,
|
||||
type CommandCenterScore,
|
||||
type CommandCenterSearchFields,
|
||||
type CommandCenterWorkspaceAgent,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
|
||||
@@ -24,41 +27,176 @@ function parseChangeRequestQuery(query: string): number | null {
|
||||
return Number.isSafeInteger(number) ? number : null;
|
||||
}
|
||||
|
||||
/** Within a match class, a match on the workspace's own text ranks ahead of one through an agent. */
|
||||
const OWN_MATCH = 0;
|
||||
const AGENT_MATCH = 1;
|
||||
/** The host matched it (typo-tolerant, server-side) but nothing here does. Archived rows only. */
|
||||
const HOST_MATCH = 2;
|
||||
|
||||
/** How a row matched: its class decides first, its score within the class. */
|
||||
interface Candidate {
|
||||
matchClass: MatchClass;
|
||||
score: CommandCenterScore;
|
||||
}
|
||||
|
||||
interface ScoredWorkspace {
|
||||
workspace: CommandCenterWorkspaceResult;
|
||||
score: CommandCenterScore | null;
|
||||
via: typeof OWN_MATCH | typeof AGENT_MATCH | typeof HOST_MATCH;
|
||||
/** The match the row shows, which is also the one it ranks by. */
|
||||
candidate: Candidate | null;
|
||||
agent: CommandCenterWorkspaceAgent | null;
|
||||
changeRequestHit: boolean;
|
||||
}
|
||||
|
||||
function searchFields(workspace: CommandCenterWorkspaceResult) {
|
||||
function ownSearchFields(workspace: CommandCenterWorkspaceResult) {
|
||||
return { visible: [workspace.title, workspace.subtitle], hidden: [] };
|
||||
}
|
||||
|
||||
/** An agent is found through its own title and its workspace's text together. */
|
||||
function agentSearchFields(
|
||||
workspace: CommandCenterWorkspaceResult,
|
||||
agent: CommandCenterWorkspaceAgent,
|
||||
) {
|
||||
return { visible: [workspace.title, workspace.subtitle, agent.title], hidden: [] };
|
||||
}
|
||||
|
||||
function scoreCandidate(query: string, fields: CommandCenterSearchFields): Candidate | null {
|
||||
const score = scoreSearchFields(query, fields);
|
||||
const matchClass = searchFieldsMatchClass(query, fields);
|
||||
return score && matchClass ? { matchClass, score } : null;
|
||||
}
|
||||
|
||||
function compareCandidates(left: Candidate, right: Candidate): number {
|
||||
return (
|
||||
compareMatchClasses(left.matchClass, right.matchClass) ||
|
||||
compareBuiltInScores(left.score, right.score)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change-request hits first, host-only matches last; then match class, the workspace's own text
|
||||
* ahead of an agent, match quality, and recency.
|
||||
*/
|
||||
function compareScoredWorkspaces(left: ScoredWorkspace, right: ScoredWorkspace): number {
|
||||
if (left.changeRequestHit !== right.changeRequestHit) {
|
||||
return left.changeRequestHit ? -1 : 1;
|
||||
}
|
||||
if (left.score && right.score) {
|
||||
const scoreDelta = compareBuiltInScores(left.score, right.score);
|
||||
if ((left.via === HOST_MATCH) !== (right.via === HOST_MATCH)) {
|
||||
return left.via === HOST_MATCH ? 1 : -1;
|
||||
}
|
||||
if (left.candidate && right.candidate) {
|
||||
const classDelta = compareMatchClasses(left.candidate.matchClass, right.candidate.matchClass);
|
||||
if (classDelta !== 0) return classDelta;
|
||||
}
|
||||
if (left.via !== right.via) return left.via - right.via;
|
||||
if (left.candidate && right.candidate) {
|
||||
const scoreDelta = compareBuiltInScores(left.candidate.score, right.candidate.score);
|
||||
if (scoreDelta !== 0) return scoreDelta;
|
||||
}
|
||||
return compareByRecency(left.workspace, right.workspace);
|
||||
}
|
||||
|
||||
export function filterAndRankWorkspaces(
|
||||
interface AgentMatch extends Candidate {
|
||||
agent: CommandCenterWorkspaceAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace's best-matching agent. Each agent is scored together with the workspace's
|
||||
* text, so a query split across the two finds it. Agents arrive most recent first, so a tie
|
||||
* keeps the newer one.
|
||||
*/
|
||||
function agentMatch(workspace: CommandCenterWorkspaceResult, query: string): AgentMatch | null {
|
||||
let best: AgentMatch | null = null;
|
||||
for (const agent of workspace.agents) {
|
||||
const candidate = scoreCandidate(query, agentSearchFields(workspace, agent));
|
||||
if (candidate && (!best || compareCandidates(candidate, best) < 0)) {
|
||||
best = { agent, ...candidate };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export type WorkspaceRanker = (
|
||||
workspaces: readonly CommandCenterWorkspaceResult[],
|
||||
query: string,
|
||||
): CommandCenterWorkspaceResult[] {
|
||||
if (!query.trim()) return filterAndRankBuiltInResults(workspaces, query, searchFields);
|
||||
options?: { keepUnmatched?: boolean },
|
||||
) => CommandCenterWorkspaceResult[];
|
||||
|
||||
const changeRequestNumber = parseChangeRequestQuery(query);
|
||||
const matches: ScoredWorkspace[] = [];
|
||||
for (const workspace of workspaces) {
|
||||
const score = scoreSearchFields(query, searchFields(workspace));
|
||||
const changeRequestHit =
|
||||
changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber;
|
||||
if (score || changeRequestHit) matches.push({ workspace, score, changeRequestHit });
|
||||
/**
|
||||
* Rank workspaces for a query, best match first. A workspace matches on its own title, project,
|
||||
* branch and change request, or on those together with the title of one of its agents. It is
|
||||
* matched through an agent only when the agent matches in a closer class than its own text
|
||||
* (see `MatchClass`), never on a change-request hit; such a match comes back naming that agent,
|
||||
* and opens it. Each row ranks by the match it shows.
|
||||
*
|
||||
* An empty query lists every workspace, most recent first. `keepUnmatched` keeps rows nothing
|
||||
* here matches, ranked last: archived rows come from the host's own search, which is
|
||||
* typo-tolerant, so the host is the authority on whether they match.
|
||||
*
|
||||
* The ranker remembers the row it made for each workspace and agent, so a row matched through
|
||||
* the same agent is the same object on every keystroke and its list row does not re-render.
|
||||
*/
|
||||
export function createWorkspaceRanker(): WorkspaceRanker {
|
||||
const viaAgent = new WeakMap<
|
||||
CommandCenterWorkspaceResult,
|
||||
Map<string, CommandCenterWorkspaceResult>
|
||||
>();
|
||||
|
||||
function matchedThrough(
|
||||
workspace: CommandCenterWorkspaceResult,
|
||||
agent: CommandCenterWorkspaceAgent,
|
||||
): CommandCenterWorkspaceResult {
|
||||
let rows = viaAgent.get(workspace);
|
||||
if (!rows) {
|
||||
rows = new Map();
|
||||
viaAgent.set(workspace, rows);
|
||||
}
|
||||
let row = rows.get(agent.id);
|
||||
if (!row) {
|
||||
row = { ...workspace, matchedAgent: agent, run: () => workspace.openAgent(agent.id) };
|
||||
rows.set(agent.id, row);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
matches.sort(compareScoredWorkspaces);
|
||||
return matches.map((match) => match.workspace);
|
||||
|
||||
return (workspaces, query, options = {}) => {
|
||||
if (!query.trim()) return [...workspaces].sort(compareByRecency);
|
||||
|
||||
const changeRequestNumber = parseChangeRequestQuery(query);
|
||||
const matches: ScoredWorkspace[] = [];
|
||||
for (const workspace of workspaces) {
|
||||
const changeRequestHit =
|
||||
changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber;
|
||||
const own = scoreCandidate(query, ownSearchFields(workspace));
|
||||
// A change-request hit is the workspace's own answer to the query.
|
||||
const throughAgent = changeRequestHit ? null : agentMatch(workspace, query);
|
||||
// An agent is named only when it matches in a closer class than the workspace's own text.
|
||||
if (
|
||||
throughAgent &&
|
||||
(!own || compareMatchClasses(throughAgent.matchClass, own.matchClass) < 0)
|
||||
) {
|
||||
matches.push({
|
||||
workspace,
|
||||
via: AGENT_MATCH,
|
||||
candidate: throughAgent,
|
||||
agent: throughAgent.agent,
|
||||
changeRequestHit,
|
||||
});
|
||||
} else if (own || changeRequestHit) {
|
||||
matches.push({ workspace, via: OWN_MATCH, candidate: own, agent: null, changeRequestHit });
|
||||
} else if (options.keepUnmatched) {
|
||||
matches.push({
|
||||
workspace,
|
||||
via: HOST_MATCH,
|
||||
candidate: null,
|
||||
agent: null,
|
||||
changeRequestHit,
|
||||
});
|
||||
}
|
||||
}
|
||||
matches.sort(compareScoredWorkspaces);
|
||||
return matches.map((match) =>
|
||||
match.agent ? matchedThrough(match.workspace, match.agent) : match.workspace,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
||||
@@ -74,7 +73,6 @@ export interface SheetHeader {
|
||||
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
|
||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFill };
|
||||
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
|
||||
const HEADER_ICON_SIZE = 16;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
nativeDialogSurface: {
|
||||
@@ -139,15 +137,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[SHEET_HEADER_CLOSE_PADDING_SCALE],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
headerIconButtonInactive: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
headerIconBox: {
|
||||
width: HEADER_ICON_SIZE,
|
||||
height: HEADER_ICON_SIZE,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
// Without a header the close button floats over the card's top-right corner.
|
||||
floatingClose: {
|
||||
position: "absolute",
|
||||
@@ -356,55 +345,31 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
|
||||
|
||||
/**
|
||||
* An icon action in a sheet header's trailing rail, drawn like the Close button so a header's
|
||||
* actions line up with it at one size. Pass it through `SheetHeader.actions`. Like `Button`, a
|
||||
* loading action shows a spinner in place of its icon and, like a disabled one, is dimmed and
|
||||
* cannot be pressed.
|
||||
* actions line up with it at one size. Pass it through `SheetHeader.actions`.
|
||||
*/
|
||||
export function SheetHeaderIconButton({
|
||||
icon: Icon,
|
||||
accessibilityLabel,
|
||||
onPress,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
testID,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
accessibilityLabel: string;
|
||||
onPress: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const inactive = disabled || loading;
|
||||
const style = useMemo(
|
||||
() => [styles.closeButton, inactive && styles.headerIconButtonInactive],
|
||||
[inactive],
|
||||
);
|
||||
const accessibilityState = useMemo(
|
||||
() => ({ disabled: inactive, busy: loading }),
|
||||
[inactive, loading],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={inactive}
|
||||
style={style}
|
||||
style={styles.closeButton}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
>
|
||||
{({ pressed }) => {
|
||||
const color = pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
if (!loading) return <Icon size={HEADER_ICON_SIZE} color={color} />;
|
||||
// The spinner sits in the icon's box, so the header does not move while it runs.
|
||||
return (
|
||||
<View style={styles.headerIconBox}>
|
||||
<LoadingSpinner size={HEADER_ICON_SIZE} color={color} />
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
{({ pressed }) => (
|
||||
<Icon size={16} color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -574,11 +539,6 @@ export interface AdaptiveModalSheetProps {
|
||||
* corner, and the compact sheet closes from its handle.
|
||||
*/
|
||||
header?: SheetHeader;
|
||||
/**
|
||||
* Leave out the header's close button when `header.back` is the only way out, as for a sheet
|
||||
* that stands in for a page of the surface beneath it. Escape still closes the sheet.
|
||||
*/
|
||||
showCloseButton?: boolean;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDismiss?: () => void;
|
||||
@@ -609,7 +569,6 @@ export interface AdaptiveModalSheetProps {
|
||||
*/
|
||||
function CompactSheetBody({
|
||||
header,
|
||||
showCloseButton,
|
||||
onClose,
|
||||
testID,
|
||||
footer,
|
||||
@@ -620,7 +579,6 @@ function CompactSheetBody({
|
||||
children,
|
||||
}: {
|
||||
header?: SheetHeader;
|
||||
showCloseButton: boolean;
|
||||
onClose: () => void;
|
||||
testID?: string;
|
||||
footer: ReactNode;
|
||||
@@ -650,14 +608,7 @@ function CompactSheetBody({
|
||||
);
|
||||
return (
|
||||
<SheetVisibleFrame footer={footerView}>
|
||||
{header ? (
|
||||
<SheetHeaderView
|
||||
header={header}
|
||||
onClose={onClose}
|
||||
showCloseButton={showCloseButton}
|
||||
testID={testID}
|
||||
/>
|
||||
) : null}
|
||||
{header ? <SheetHeaderView header={header} onClose={onClose} testID={testID} /> : null}
|
||||
<View style={[styles.compactStaticContent, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
@@ -684,7 +635,6 @@ function CompactSheetBody({
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
header,
|
||||
showCloseButton = true,
|
||||
visible,
|
||||
onClose,
|
||||
onDismiss,
|
||||
@@ -812,7 +762,6 @@ export function AdaptiveModalSheet({
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<CompactSheetBody
|
||||
header={header}
|
||||
showCloseButton={showCloseButton}
|
||||
onClose={onClose}
|
||||
testID={testID}
|
||||
footer={footerView}
|
||||
@@ -832,9 +781,7 @@ export function AdaptiveModalSheet({
|
||||
desktopHeight == null ? styles.desktopStaticContent : styles.compactStaticContent;
|
||||
const cardInner = (
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
{header ? (
|
||||
<SheetHeaderView header={header} onClose={onClose} showCloseButton={showCloseButton} />
|
||||
) : null}
|
||||
{header ? <SheetHeaderView header={header} onClose={onClose} /> : null}
|
||||
<View style={[scrollable ? styles.desktopScrollContainer : desktopStaticStyle, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
|
||||
@@ -17,11 +17,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive, ChevronRight } from "lucide-react-native";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { HighlightedText } from "@/components/ui/highlighted-text";
|
||||
import { ArchivedBadge } from "@/components/archived-badge";
|
||||
import { StatusBadge, type StatusBadgeVariant } from "@/components/ui/status-badge";
|
||||
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
|
||||
|
||||
@@ -99,36 +100,30 @@ function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string {
|
||||
|
||||
function SessionBadge({
|
||||
label,
|
||||
icon,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
label: string;
|
||||
icon?: ReactElement;
|
||||
tone?: "neutral" | "warning" | "danger";
|
||||
}) {
|
||||
let variant: StatusBadgeVariant = "muted";
|
||||
if (tone === "warning") variant = "warning";
|
||||
else if (tone === "danger") variant = "error";
|
||||
return <StatusBadge label={label} variant={variant} leading={icon} />;
|
||||
return <StatusBadge label={label} variant={variant} />;
|
||||
}
|
||||
|
||||
function SessionRowBadges({
|
||||
agent,
|
||||
archivedIcon,
|
||||
pendingPermissionCount,
|
||||
showDesktopAttention,
|
||||
}: {
|
||||
agent: AggregatedAgent;
|
||||
archivedIcon: ReactElement;
|
||||
pendingPermissionCount: number;
|
||||
showDesktopAttention: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{agent.archivedAt ? (
|
||||
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
|
||||
) : null}
|
||||
{agent.archivedAt ? <ArchivedBadge /> : null}
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<SessionBadge
|
||||
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
|
||||
@@ -214,10 +209,6 @@ function SessionRow({
|
||||
const handlePress = useCallback(() => onPress(agent), [onPress, agent]);
|
||||
const handleLongPress = useCallback(() => onLongPress(agent), [onLongPress, agent]);
|
||||
|
||||
const archivedIcon = useMemo(
|
||||
() => <Archive size={theme.fontSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[theme.fontSize.sm, theme.colors.foregroundMuted],
|
||||
);
|
||||
const showDesktopAttention =
|
||||
!isMobile && showAttentionIndicator && Boolean(agent.requiresAttention);
|
||||
|
||||
@@ -261,7 +252,6 @@ function SessionRow({
|
||||
) : null}
|
||||
<SessionRowBadges
|
||||
agent={agent}
|
||||
archivedIcon={archivedIcon}
|
||||
pendingPermissionCount={pendingPermissionCount}
|
||||
showDesktopAttention={showDesktopAttention}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Archive } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
|
||||
const ThemedArchive = withUnistyles(Archive, (theme) => ({
|
||||
size: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
const ARCHIVE_ICON = <ThemedArchive />;
|
||||
|
||||
/** Marks a session or workspace the user archived, wherever archived items are listed. */
|
||||
export function ArchivedBadge() {
|
||||
const { t } = useTranslation();
|
||||
return <StatusBadge label={t("agentList.badges.archived")} leading={ARCHIVE_ICON} />;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { persistAttachmentFromBlob } from "@/attachments/service";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { WindowLayoutProvider } from "@/window-layout";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
|
||||
|
||||
interface FrameSample {
|
||||
image: { x: number; y: number; width: number; height: number } | null;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
function nextFrame(): Promise<number> {
|
||||
return new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
|
||||
function createPng(width: number, height: number): Promise<Blob> {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas 2D context unavailable");
|
||||
context.fillStyle = "#3a7";
|
||||
context.fillRect(0, 0, width, height);
|
||||
return canvas.convertToBlob({ type: "image/png" });
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
|
||||
function isPainted(element: Element): boolean {
|
||||
for (let node: Element | null = element; node; node = node.parentElement) {
|
||||
if (Number(getComputedStyle(node).opacity) === 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** What the next paint will show: the visible lightbox image box, and whether the error is up. */
|
||||
function sampleFrame(): FrameSample {
|
||||
const image = document.querySelector('[data-testid="attachment-lightbox-image"]');
|
||||
const rect = image?.getBoundingClientRect();
|
||||
const visible = image && rect && rect.width > 0 && rect.height > 0 && isPainted(image);
|
||||
return {
|
||||
image: visible
|
||||
? {
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
}
|
||||
: null,
|
||||
error: (document.body.textContent ?? "").includes("Couldn't load image"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Samples every frame from the open until the image has held one box for 20 frames. */
|
||||
async function recordOpening(open: () => void): Promise<FrameSample[]> {
|
||||
const samples: FrameSample[] = [];
|
||||
open();
|
||||
let stableFrames = 0;
|
||||
for (let frame = 0; frame < 300 && stableFrames < 20; frame += 1) {
|
||||
await nextFrame();
|
||||
const sample = sampleFrame();
|
||||
const previous = samples.at(-1);
|
||||
stableFrames =
|
||||
sample.image && JSON.stringify(sample.image) === JSON.stringify(previous?.image)
|
||||
? stableFrames + 1
|
||||
: 0;
|
||||
samples.push(sample);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("React", React);
|
||||
await i18n.changeLanguage("en");
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderLightbox(source: ImageLightboxSource | null) {
|
||||
root.render(
|
||||
<SafeAreaProvider>
|
||||
<WindowLayoutProvider>
|
||||
<AttachmentLightbox source={source} onClose={noop} />
|
||||
</WindowLayoutProvider>
|
||||
</SafeAreaProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function expectOpeningPaintsOnlyTheFittedImage(source: ImageLightboxSource) {
|
||||
const samples = await recordOpening(() => renderLightbox(source));
|
||||
const fitted = samples.at(-1)?.image;
|
||||
|
||||
expect(fitted, "the image never settled").toBeTruthy();
|
||||
expect(samples.filter((sample) => sample.error)).toEqual([]);
|
||||
expect(
|
||||
samples.filter(
|
||||
(sample) => sample.image && JSON.stringify(sample.image) !== JSON.stringify(fitted),
|
||||
),
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
describe("AttachmentLightbox opening in the browser", () => {
|
||||
it("paints the fitted attachment image as its first image frame", async () => {
|
||||
const metadata = await persistAttachmentFromBlob({
|
||||
blob: await createPng(2400, 1600),
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
});
|
||||
|
||||
await expectOpeningPaintsOnlyTheFittedImage({ type: "attachment", metadata });
|
||||
});
|
||||
|
||||
it("paints only the fitted image when the same attachment opens again", async () => {
|
||||
const metadata = await persistAttachmentFromBlob({
|
||||
blob: await createPng(2400, 1600),
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
});
|
||||
const source: ImageLightboxSource = { type: "attachment", metadata };
|
||||
|
||||
await expectOpeningPaintsOnlyTheFittedImage(source);
|
||||
await act(async () => renderLightbox(null));
|
||||
await expectOpeningPaintsOnlyTheFittedImage(source);
|
||||
});
|
||||
|
||||
it("shows an image the browser reports no natural size for", async () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"></svg>';
|
||||
const uri = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
|
||||
|
||||
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
|
||||
});
|
||||
|
||||
it("paints the fitted timeline image as its first image frame", async () => {
|
||||
const uri = URL.createObjectURL(await createPng(2400, 1600));
|
||||
|
||||
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import type { AttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
|
||||
|
||||
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
|
||||
@@ -38,9 +39,9 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
|
||||
return {
|
||||
theme: hoistedTheme,
|
||||
imageMetadata: hoistedImageMetadata,
|
||||
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
|
||||
() => "blob:preview",
|
||||
),
|
||||
useAttachmentPreviewUrlMock: vi.fn<
|
||||
(metadata: AttachmentMetadata | null) => AttachmentPreviewUrl
|
||||
>(() => ({ status: "ready", url: "blob:preview" })),
|
||||
};
|
||||
});
|
||||
const attachmentSource: ImageLightboxSource = { type: "attachment", metadata: imageMetadata };
|
||||
@@ -73,10 +74,13 @@ vi.mock("react-i18next", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/window-layout", () => ({
|
||||
WindowSafeArea: ({ children }: { children?: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock("react-native-gesture-handler", () => ({
|
||||
GestureHandlerRootView: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-testid": "gesture-handler-root" }, children),
|
||||
@@ -151,7 +155,7 @@ beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
useAttachmentPreviewUrlMock.mockReturnValue("blob:preview");
|
||||
useAttachmentPreviewUrlMock.mockReturnValue({ status: "ready", url: "blob:preview" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -229,14 +233,22 @@ describe("AttachmentLightbox", () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows error text when the preview URL resolves to null", () => {
|
||||
useAttachmentPreviewUrlMock.mockReturnValue(null);
|
||||
it("shows error text when the preview URL fails to resolve", () => {
|
||||
useAttachmentPreviewUrlMock.mockReturnValue({ status: "failed" });
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
|
||||
|
||||
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||
expect(document.body.textContent ?? "").toContain("Couldn't load image");
|
||||
});
|
||||
|
||||
it("shows neither the image nor an error while the preview URL resolves", () => {
|
||||
useAttachmentPreviewUrlMock.mockReturnValue({ status: "loading" });
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
|
||||
|
||||
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||
expect(document.body.textContent ?? "").not.toContain("Couldn't load image");
|
||||
});
|
||||
|
||||
it("closes on Escape key on web", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
|
||||
|
||||
@@ -4,17 +4,21 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import {
|
||||
type AttachmentPreviewUrl,
|
||||
useAttachmentPreviewUrl,
|
||||
} from "@/attachments/use-attachment-preview-url";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||
import { useGlobalOverlayLayer } from "@/lib/overlay-root";
|
||||
import { WindowSafeArea } from "@/window-layout";
|
||||
|
||||
export type ImageLightboxSource =
|
||||
| { type: "attachment"; metadata: AttachmentMetadata }
|
||||
@@ -31,10 +35,13 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
|
||||
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const metadata = source?.type === "attachment" ? source.metadata : null;
|
||||
const attachmentUrl = useAttachmentPreviewUrl(metadata);
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
const attachmentPreview = useAttachmentPreviewUrl(metadata);
|
||||
const image: AttachmentPreviewUrl =
|
||||
source?.type === "uri" ? { status: "ready", url: source.uri } : attachmentPreview;
|
||||
const url = image.status === "ready" ? image.url : null;
|
||||
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
||||
const [errored, setErrored] = useState(false);
|
||||
const failed = errored || image.status === "failed";
|
||||
const modalLayer = useGlobalOverlayLayer("modal", source !== null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,14 +83,12 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasError = errored || !url;
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<ModalRoot style={styles.root}>
|
||||
{/* The modal is its own window: its insets are the window's, not the ones left where the
|
||||
image was opened (inside a sheet tab, the bottom one is already spent). */}
|
||||
<SafeAreaProvider>
|
||||
{/* The modal draws over the whole window: its insets are the window's, not the ones left
|
||||
where the image was opened (inside a sheet tab, the bottom one is already spent). */}
|
||||
<WindowSafeArea>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
@@ -94,9 +99,10 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<LightboxImageArea>
|
||||
{hasError ? (
|
||||
{failed ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
) : null}
|
||||
{!failed && url ? (
|
||||
<ZoomableImage
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
actions={actions}
|
||||
@@ -108,11 +114,11 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
testID="attachment-lightbox"
|
||||
uri={url}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</LightboxImageArea>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</SafeAreaProvider>
|
||||
</WindowSafeArea>
|
||||
</ModalRoot>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -133,9 +133,10 @@ export function AttachmentLabel({ icon, title, subtitle }: AttachmentLabelProps)
|
||||
|
||||
/** Square image preview pill body. */
|
||||
export function AttachmentThumbnail({ metadata }: { metadata: AttachmentMetadata }) {
|
||||
const uri = useAttachmentPreviewUrl(metadata);
|
||||
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
|
||||
if (!uri) {
|
||||
const preview = useAttachmentPreviewUrl(metadata);
|
||||
const uri = preview.status === "ready" ? preview.url : null;
|
||||
const source = useMemo(() => (uri ? { uri } : null), [uri]);
|
||||
if (!source) {
|
||||
return <View style={styles.thumbnailPlaceholder} />;
|
||||
}
|
||||
return <Image source={source} style={styles.thumbnail} />;
|
||||
|
||||
@@ -11,6 +11,10 @@ export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
const SCROLL_ENABLED_FLEX_STYLE = { flex: 1 };
|
||||
|
||||
// A drop remounts every row between the drag's start and end slots (see the draggable-flatlist
|
||||
// patch), so state held inside a rendered row resets when a drop moves it. Keep state that must
|
||||
// survive a reorder in the list's owner, keyed by row.
|
||||
|
||||
export function DraggableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
@@ -117,6 +121,10 @@ export function DraggableList<T>({
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
scrollEnabled={scrollEnabled}
|
||||
// A list that doesn't scroll shows every row, so it renders them all in its first commit.
|
||||
// FlatList's default first render is 10 rows; a nested list remounted by a drop would paint
|
||||
// 10 rows, then grow on a later commit.
|
||||
initialNumToRender={scrollEnabled ? undefined : data.length}
|
||||
extraData={extraData}
|
||||
simultaneousHandlers={simultaneousHandlers}
|
||||
dragGestureHostPresented={gestureHostPresented}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type LayoutChangeEvent } from "react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { PackagePlus } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { SearchField } from "@/components/ui/search-field";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { rankProviderCatalog } from "@/components/provider-catalog-search";
|
||||
import {
|
||||
useAcpProviderCatalog,
|
||||
type AcpProviderCatalogItem,
|
||||
} from "@/hooks/use-acp-provider-catalog";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { SPACING, type Theme } from "@/styles/theme";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
interface ProviderCatalogListProps {
|
||||
serverId: string;
|
||||
installingProviderId: string | null;
|
||||
onInstall: (entry: AcpProviderCatalogItem) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const PROVIDER_FALLBACK_ICON_SIZE = 20;
|
||||
const PROVIDER_REMOTE_ICON_SIZE = 24;
|
||||
const TILE_GAP = SPACING[3];
|
||||
/** Narrowest tile that keeps the icon, type badge, install link, and Add on one row. */
|
||||
const MIN_TILE_WIDTH = 300;
|
||||
const MAX_COLUMNS = 4;
|
||||
|
||||
const ThemedPackagePlus = withUnistyles(PackagePlus);
|
||||
const ThemedSvgXml = withUnistyles(SvgXml);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
function columnCountForWidth(width: number): number {
|
||||
const fits = Math.floor((width + TILE_GAP) / (MIN_TILE_WIDTH + TILE_GAP));
|
||||
return Math.min(MAX_COLUMNS, Math.max(1, fits));
|
||||
}
|
||||
|
||||
interface CatalogTileProps {
|
||||
entry: AcpProviderCatalogItem;
|
||||
/** `undefined` until the grid is measured, or in a single column; the tile fills the row. */
|
||||
width: number | undefined;
|
||||
installing: boolean;
|
||||
onInstall: (entry: AcpProviderCatalogItem) => void;
|
||||
}
|
||||
|
||||
function CatalogTile({ entry, width, installing, onInstall }: CatalogTileProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleInstall = useCallback(() => {
|
||||
onInstall(entry);
|
||||
}, [entry, onInstall]);
|
||||
const tileStyle = useMemo(
|
||||
() => [settingsStyles.card, styles.tile, width === undefined ? styles.tileFull : { width }],
|
||||
[width],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={tileStyle} testID={`provider-catalog-tile-${entry.id}`}>
|
||||
<View style={styles.iconFrame}>
|
||||
{entry.iconSvg ? (
|
||||
<ThemedSvgXml
|
||||
xml={entry.iconSvg}
|
||||
width={PROVIDER_REMOTE_ICON_SIZE}
|
||||
height={PROVIDER_REMOTE_ICON_SIZE}
|
||||
uniProps={foregroundColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<ThemedPackagePlus size={PROVIDER_FALLBACK_ICON_SIZE} uniProps={foregroundColorMapping} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.details}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{entry.title}
|
||||
</Text>
|
||||
<View style={styles.metaRow}>
|
||||
{/* The catalog is the ACP registry, so every entry it offers is an ACP provider. */}
|
||||
<StatusBadge label={t("providerCatalog.types.acp")} size="xs" />
|
||||
<ExternalLink
|
||||
href={entry.installLink}
|
||||
label={t("providerCatalog.actions.installInstructions")}
|
||||
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
|
||||
provider: entry.title,
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={installing}
|
||||
loading={installing}
|
||||
onPress={handleInstall}
|
||||
accessibilityLabel={t("providerCatalog.actions.addProvider", { provider: entry.title })}
|
||||
style={styles.addButton}
|
||||
testID={`install-provider-${entry.id}`}
|
||||
>
|
||||
{installing ? t("providerCatalog.actions.adding") : t("providerCatalog.actions.add")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderCatalogList({
|
||||
serverId,
|
||||
installingProviderId,
|
||||
onInstall,
|
||||
}: ProviderCatalogListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { entries: catalogEntries } = useAcpProviderCatalog();
|
||||
const { entries: providerEntries } = useProvidersSnapshot(serverId);
|
||||
const [search, setSearch] = useState("");
|
||||
const [gridWidth, setGridWidth] = useState<number | null>(null);
|
||||
|
||||
const installedIds = useMemo(
|
||||
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
|
||||
[providerEntries],
|
||||
);
|
||||
|
||||
const availableEntries = useMemo(
|
||||
() =>
|
||||
rankProviderCatalog(
|
||||
catalogEntries.filter((entry) => !installedIds.has(entry.id)),
|
||||
search,
|
||||
),
|
||||
[catalogEntries, installedIds, search],
|
||||
);
|
||||
|
||||
const handleGridLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setGridWidth(event.nativeEvent.layout.width);
|
||||
}, []);
|
||||
|
||||
const tileWidth = useMemo(() => {
|
||||
if (gridWidth === null) return undefined;
|
||||
const columns = columnCountForWidth(gridWidth);
|
||||
if (columns === 1) return undefined;
|
||||
return Math.floor((gridWidth - TILE_GAP * (columns - 1)) / columns);
|
||||
}, [gridWidth]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.searchRail}>
|
||||
<SearchField
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder={t("providerCatalog.search")}
|
||||
clearAccessibilityLabel={t("providerCatalog.clearSearch")}
|
||||
testID="provider-catalog-search"
|
||||
clearTestID="provider-catalog-search-clear"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{availableEntries.length === 0 ? (
|
||||
<View style={[settingsStyles.card, styles.stateBox]}>
|
||||
<Text style={styles.stateText}>{t("providerCatalog.noProviders")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.grid} onLayout={handleGridLayout}>
|
||||
{availableEntries.map((entry) => (
|
||||
<CatalogTile
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
width={tileWidth}
|
||||
installing={installingProviderId === entry.id}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
searchRail: {
|
||||
flexDirection: "row",
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: TILE_GAP,
|
||||
},
|
||||
tile: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
tileFull: {
|
||||
width: "100%",
|
||||
},
|
||||
details: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
iconFrame: {
|
||||
flexShrink: 0,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
addButton: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
name: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
stateBox: {
|
||||
minHeight: 96,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
stateText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
resolveProviderDiscoveredModels,
|
||||
type ProviderDiscoveredModelsCache,
|
||||
} from "./discovered-models";
|
||||
} from "./provider-diagnostic-models";
|
||||
|
||||
const piModel: AgentModelDefinition = {
|
||||
provider: "pi",
|
||||
@@ -0,0 +1,869 @@
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
|
||||
import {
|
||||
resolveProviderDiscoveredModels,
|
||||
type ProviderDiscoveredModelsCache,
|
||||
} from "./provider-diagnostic-models";
|
||||
|
||||
interface ProviderDiagnosticSheetProps {
|
||||
provider: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
function rankModels<T>(items: T[], query: string, fields: (item: T) => string[]): T[] {
|
||||
if (!query.trim()) return items;
|
||||
const scored = items
|
||||
.map((item) => ({ item, score: scoreTextFields(query, fields(item)) }))
|
||||
.filter(
|
||||
(entry): entry is { item: T; score: NonNullable<typeof entry.score> } => entry.score !== null,
|
||||
);
|
||||
scored.sort((a, b) => compareMatchScores(a.score, b.score));
|
||||
return scored.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
function DiscoveredModelRow({ model }: { model: AgentModelDefinition }) {
|
||||
return (
|
||||
<View style={sheetStyles.modelRow}>
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
>
|
||||
{model.id}
|
||||
</Text>
|
||||
{model.description ? (
|
||||
<Text style={sheetStyles.descriptionInline} numberOfLines={1}>
|
||||
{model.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomModelRow({
|
||||
model,
|
||||
deleting,
|
||||
onDelete,
|
||||
}: {
|
||||
model: ProviderProfileModel;
|
||||
deleting: boolean;
|
||||
onDelete: (modelId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]);
|
||||
const deleteButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
sheetStyles.iconButton,
|
||||
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
|
||||
deleting ? sheetStyles.disabled : null,
|
||||
],
|
||||
[deleting],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={sheetStyles.modelRow}>
|
||||
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
>
|
||||
{model.id}
|
||||
</Text>
|
||||
<View style={sheetStyles.modelRowFiller} />
|
||||
<Pressable
|
||||
onPress={handleDelete}
|
||||
disabled={deleting}
|
||||
hitSlop={8}
|
||||
style={deleteButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.providers.models.removeModel", { id: model.id })}
|
||||
>
|
||||
<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ title, count, hint }: { title: string; count?: number; hint?: string }) {
|
||||
return (
|
||||
<View style={sheetStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>{title}</Text>
|
||||
<View style={sheetStyles.sectionHeaderMeta}>
|
||||
{count !== undefined ? (
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>{count}</Text>
|
||||
) : null}
|
||||
{count !== undefined && hint ? (
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>·</Text>
|
||||
) : null}
|
||||
{hint ? <Text style={settingsStyles.sectionHeaderTitle}>{hint}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AddCustomModelSubSheet({
|
||||
provider,
|
||||
serverId,
|
||||
visible,
|
||||
onClose,
|
||||
refresh,
|
||||
}: {
|
||||
provider: string;
|
||||
serverId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [input, setInput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const additionalModels = useMemo(
|
||||
() => config?.providers?.[provider]?.additionalModels ?? [],
|
||||
[config?.providers, provider],
|
||||
);
|
||||
const trimmed = input.trim();
|
||||
const canAdd = trimmed.length > 0 && !additionalModels.some((model) => model.id === trimmed);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setInput("");
|
||||
setError(null);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
if (!canAdd) return;
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
void patchConfig({
|
||||
providers: {
|
||||
[provider]: {
|
||||
additionalModels: [...additionalModels, { id: trimmed, label: trimmed }],
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => refresh([provider]))
|
||||
.then(() => onClose())
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : t("settings.providers.models.failedToSave"));
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
}, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, t, trimmed]);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.providers.models.addCustomTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
desktopMaxWidth={420}
|
||||
snapPoints={ADD_SNAP_POINTS}
|
||||
testID="add-custom-model-sheet"
|
||||
>
|
||||
<View style={sheetStyles.formGroup}>
|
||||
<Text style={sheetStyles.formLabel}>{t("settings.providers.models.modelId")}</Text>
|
||||
<AdaptiveTextInput
|
||||
initialValue={input}
|
||||
resetKey={`add-custom-${visible}`}
|
||||
onChangeText={setInput}
|
||||
onSubmitEditing={handleAdd}
|
||||
placeholder={t("settings.providers.models.modelIdPlaceholder")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[sheetStyles.formInput, isWeb && { outlineStyle: "none" }]}
|
||||
/>
|
||||
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
|
||||
<View style={sheetStyles.formActions}>
|
||||
<Button variant="secondary" onPress={onClose} disabled={saving}>
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="default" onPress={handleAdd} disabled={!canAdd || saving}>
|
||||
{saving ? t("settings.providers.models.adding") : t("settings.providers.models.add")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagnosticSubSheet({
|
||||
provider,
|
||||
serverId,
|
||||
visible,
|
||||
onClose,
|
||||
}: {
|
||||
provider: string;
|
||||
serverId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchDiagnostic = useCallback(async () => {
|
||||
if (!client) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await client.getProviderDiagnostic(provider);
|
||||
setDiagnostic(result.diagnostic);
|
||||
} catch (err) {
|
||||
setDiagnostic(
|
||||
err instanceof Error ? err.message : t("settings.providers.diagnostic.failedToFetch"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, provider, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
void fetchDiagnostic();
|
||||
} else {
|
||||
setDiagnostic(null);
|
||||
}
|
||||
}, [visible, fetchDiagnostic]);
|
||||
|
||||
const refreshButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
sheetStyles.iconButton,
|
||||
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
|
||||
loading ? sheetStyles.disabled : null,
|
||||
],
|
||||
[loading],
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
void fetchDiagnostic();
|
||||
}, [fetchDiagnostic]);
|
||||
|
||||
const copyButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
sheetStyles.iconButton,
|
||||
(Boolean(hovered) || pressed) && Boolean(diagnostic) && sheetStyles.iconButtonHovered,
|
||||
diagnostic ? null : sheetStyles.disabled,
|
||||
],
|
||||
[diagnostic],
|
||||
);
|
||||
|
||||
const handleCopyPress = useCallback(() => {
|
||||
if (!diagnostic) return;
|
||||
void Clipboard.setStringAsync(diagnostic)
|
||||
.then(() => toast.copied(t("settings.providers.diagnostic.copyLabel")))
|
||||
.catch(() => toast.error(t("settings.providers.diagnostic.copyFailed")));
|
||||
}, [diagnostic, t, toast]);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: t("settings.providers.diagnostic.title"),
|
||||
actions: (
|
||||
<View style={sheetStyles.headerActions}>
|
||||
<Pressable
|
||||
onPress={handleCopyPress}
|
||||
disabled={!diagnostic}
|
||||
hitSlop={8}
|
||||
style={copyButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.providers.diagnostic.copyAccessibility")}
|
||||
>
|
||||
<Copy size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleRefreshPress}
|
||||
disabled={loading}
|
||||
hitSlop={8}
|
||||
style={refreshButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
loading
|
||||
? t("settings.providers.diagnostic.refreshingAccessibility")
|
||||
: t("settings.providers.diagnostic.refreshAccessibility")
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
),
|
||||
}),
|
||||
[
|
||||
copyButtonStyle,
|
||||
diagnostic,
|
||||
handleCopyPress,
|
||||
handleRefreshPress,
|
||||
loading,
|
||||
refreshButtonStyle,
|
||||
t,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.sm,
|
||||
],
|
||||
);
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (loading && !diagnostic) {
|
||||
body = (
|
||||
<SurfaceCard key={visible ? "visible" : "hidden"}>
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
|
||||
</View>
|
||||
</SurfaceCard>
|
||||
);
|
||||
} else if (diagnostic) {
|
||||
body = (
|
||||
<ScrollableCodeSurface key={visible ? "visible" : "hidden"} maxHeight={480}>
|
||||
{diagnostic}
|
||||
</ScrollableCodeSurface>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<SurfaceCard key={visible ? "visible" : "hidden"}>
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
|
||||
</View>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={DIAGNOSTIC_SNAP_POINTS}
|
||||
scrollable={false}
|
||||
testID="provider-diagnostic-sheet"
|
||||
>
|
||||
{body}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderModalBodyProps {
|
||||
discoveredCount: number;
|
||||
additionalCount: number;
|
||||
providerSnapshotRefreshing: boolean;
|
||||
providerErrorMessage: string | null;
|
||||
modelsRefreshing: boolean;
|
||||
searchActive: boolean;
|
||||
filteredDiscovered: AgentModelDefinition[];
|
||||
filteredCustom: ProviderProfileModel[];
|
||||
deletingModelId: string | null;
|
||||
onRefresh: () => void;
|
||||
onDeleteCustom: (modelId: string) => void;
|
||||
theme: { iconSize: { md: number }; colors: { foregroundMuted: string } };
|
||||
}
|
||||
|
||||
interface ProviderSheetFooterInput {
|
||||
fetchedAt: string | null;
|
||||
isCompact: boolean;
|
||||
modelsRefreshing: boolean;
|
||||
t: TFunction;
|
||||
onOpenAddSheet: () => void;
|
||||
onOpenDiagSheet: () => void;
|
||||
onRefreshModels: () => void;
|
||||
}
|
||||
|
||||
function renderProviderSheetFooter({
|
||||
fetchedAt,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
onOpenAddSheet,
|
||||
onOpenDiagSheet,
|
||||
onRefreshModels,
|
||||
}: ProviderSheetFooterInput) {
|
||||
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
|
||||
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
|
||||
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
|
||||
return (
|
||||
<View style={contentStyle}>
|
||||
{fetchedAt || !isCompact ? (
|
||||
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
|
||||
) : null}
|
||||
<View style={actionsStyle}>
|
||||
<Button variant="secondary" leftIcon={Plus} onPress={onOpenAddSheet} style={buttonStyle}>
|
||||
{t("settings.providers.models.addModel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={FileText}
|
||||
onPress={onOpenDiagSheet}
|
||||
style={buttonStyle}
|
||||
>
|
||||
{t("settings.providers.diagnostic.button")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftIcon={modelsRefreshing ? undefined : RotateCw}
|
||||
onPress={onRefreshModels}
|
||||
disabled={modelsRefreshing}
|
||||
style={buttonStyle}
|
||||
>
|
||||
{modelsRefreshing
|
||||
? t("settings.providers.diagnostic.refreshing")
|
||||
: t("settings.providers.diagnostic.refresh")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderUpdatedLabel({
|
||||
fetchedAt,
|
||||
isCompact,
|
||||
}: {
|
||||
fetchedAt: string | null;
|
||||
isCompact: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = useTimeAgo(fetchedAt ? new Date(fetchedAt) : null);
|
||||
const style = isCompact
|
||||
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
|
||||
: sheetStyles.footerMeta;
|
||||
return (
|
||||
<Text style={style} numberOfLines={1}>
|
||||
{fetchedAt ? t("settings.providers.models.updated", { time: label }) : ""}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
discoveredCount,
|
||||
additionalCount,
|
||||
providerSnapshotRefreshing,
|
||||
providerErrorMessage,
|
||||
modelsRefreshing,
|
||||
searchActive,
|
||||
filteredDiscovered,
|
||||
filteredCustom,
|
||||
deletingModelId,
|
||||
onRefresh,
|
||||
onDeleteCustom,
|
||||
theme,
|
||||
} = props;
|
||||
|
||||
if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (discoveredCount === 0 && additionalCount === 0 && providerErrorMessage) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
|
||||
<Button variant="default" onPress={onRefresh} disabled={modelsRefreshing}>
|
||||
{modelsRefreshing
|
||||
? t("settings.providers.models.retrying")
|
||||
: t("settings.providers.models.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noSearchMatches")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (discoveredCount === 0 && additionalCount === 0) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noneDetected")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{filteredDiscovered.length > 0 ? (
|
||||
<View style={sheetStyles.section}>
|
||||
<SectionHeader
|
||||
title={t("settings.providers.models.discovered")}
|
||||
count={filteredDiscovered.length}
|
||||
/>
|
||||
<View style={settingsStyles.card}>
|
||||
{filteredDiscovered.map((model) => (
|
||||
<DiscoveredModelRow key={model.id} model={model} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
{filteredCustom.length > 0 ? (
|
||||
<View style={sheetStyles.section}>
|
||||
<SectionHeader
|
||||
title={t("settings.providers.models.custom")}
|
||||
count={filteredCustom.length}
|
||||
/>
|
||||
<View style={settingsStyles.card}>
|
||||
{filteredCustom.map((model) => (
|
||||
<CustomModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
deleting={deletingModelId === model.id}
|
||||
onDelete={onDeleteCustom}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderDiagnosticSheet({
|
||||
provider,
|
||||
visible,
|
||||
onClose,
|
||||
serverId,
|
||||
}: ProviderDiagnosticSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [query, setQuery] = useState("");
|
||||
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||
const [diagSheetOpen, setDiagSheetOpen] = useState(false);
|
||||
const [deletingModelId, setDeletingModelId] = useState<string | null>(null);
|
||||
|
||||
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
||||
const providerEntry = useMemo(
|
||||
() => snapshotEntries?.find((entry) => entry.provider === provider),
|
||||
[snapshotEntries, provider],
|
||||
);
|
||||
const additionalModels = useMemo(
|
||||
() => config?.providers?.[provider]?.additionalModels ?? [],
|
||||
[config?.providers, provider],
|
||||
);
|
||||
const providerSnapshotRefreshing = providerEntry?.status === "loading";
|
||||
const providerErrorMessage =
|
||||
providerEntry?.status === "error"
|
||||
? (providerEntry.error ?? t("settings.providers.diagnostic.unknownError"))
|
||||
: null;
|
||||
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
|
||||
|
||||
const stableDiscoveredRef = useRef<ProviderDiscoveredModelsCache | null>(null);
|
||||
const currentModels = providerEntry?.models;
|
||||
const { models: discoveredModels, cache: nextDiscoveredCache } = resolveProviderDiscoveredModels({
|
||||
serverId,
|
||||
provider,
|
||||
currentModels,
|
||||
providerSnapshotRefreshing,
|
||||
previousCache: stableDiscoveredRef.current,
|
||||
});
|
||||
stableDiscoveredRef.current = nextDiscoveredCache;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setQuery("");
|
||||
setAddSheetOpen(false);
|
||||
setDiagSheetOpen(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const q = query.trim();
|
||||
const filteredDiscovered = useMemo(
|
||||
() => rankModels(discoveredModels, q, (m) => [m.label, m.id, m.description ?? ""]),
|
||||
[discoveredModels, q],
|
||||
);
|
||||
const filteredCustom = useMemo(
|
||||
() => rankModels(additionalModels, q, (m) => [m.label, m.id]),
|
||||
[additionalModels, q],
|
||||
);
|
||||
|
||||
const handleRefreshModels = useCallback(() => {
|
||||
void refresh([provider]);
|
||||
}, [provider, refresh]);
|
||||
|
||||
const handleOpenAddSheet = useCallback(() => setAddSheetOpen(true), []);
|
||||
const handleCloseAddSheet = useCallback(() => setAddSheetOpen(false), []);
|
||||
const handleOpenDiagSheet = useCallback(() => setDiagSheetOpen(true), []);
|
||||
const handleCloseDiagSheet = useCallback(() => setDiagSheetOpen(false), []);
|
||||
|
||||
const handleDeleteCustom = useCallback(
|
||||
(modelId: string) => {
|
||||
setDeletingModelId(modelId);
|
||||
void patchConfig({
|
||||
providers: {
|
||||
[provider]: {
|
||||
additionalModels: additionalModels.filter((model) => model.id !== modelId),
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => refresh([provider]))
|
||||
.finally(() => {
|
||||
setDeletingModelId((current) => (current === modelId ? null : current));
|
||||
});
|
||||
},
|
||||
[additionalModels, patchConfig, provider, refresh],
|
||||
);
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: providerLabel,
|
||||
search: {
|
||||
onChange: setQuery,
|
||||
placeholder: t("settings.providers.models.searchPlaceholder"),
|
||||
testID: "provider-settings-search",
|
||||
},
|
||||
}),
|
||||
[providerLabel, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdaptiveModalSheet
|
||||
header={sheetHeader}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
testID="provider-settings-sheet"
|
||||
footer={renderProviderSheetFooter({
|
||||
fetchedAt: providerEntry?.fetchedAt ?? null,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
onOpenAddSheet: handleOpenAddSheet,
|
||||
onOpenDiagSheet: handleOpenDiagSheet,
|
||||
onRefreshModels: handleRefreshModels,
|
||||
})}
|
||||
snapPoints={MAIN_SNAP_POINTS}
|
||||
>
|
||||
<ProviderModalBody
|
||||
discoveredCount={discoveredModels.length}
|
||||
additionalCount={additionalModels.length}
|
||||
providerSnapshotRefreshing={providerSnapshotRefreshing}
|
||||
providerErrorMessage={providerErrorMessage}
|
||||
modelsRefreshing={modelsRefreshing}
|
||||
searchActive={Boolean(q)}
|
||||
filteredDiscovered={filteredDiscovered}
|
||||
filteredCustom={filteredCustom}
|
||||
deletingModelId={deletingModelId}
|
||||
onRefresh={handleRefreshModels}
|
||||
onDeleteCustom={handleDeleteCustom}
|
||||
theme={theme}
|
||||
/>
|
||||
</AdaptiveModalSheet>
|
||||
<AddCustomModelSubSheet
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
visible={addSheetOpen}
|
||||
onClose={handleCloseAddSheet}
|
||||
refresh={refresh}
|
||||
/>
|
||||
<DiagnosticSubSheet
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
visible={diagSheetOpen}
|
||||
onClose={handleCloseDiagSheet}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const sheetStyles = StyleSheet.create((theme) => ({
|
||||
mutedText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
monoHint: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
},
|
||||
descriptionInline: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
formInput: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foreground,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
iconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
section: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionHeaderMeta: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
modelRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
gap: theme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
modelTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
customModelText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
modelRowFiller: {
|
||||
flex: 1,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[8],
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
footerContent: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
compactFooterContent: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
footerMeta: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
compactFooterMeta: {
|
||||
flex: 0,
|
||||
},
|
||||
footerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
compactFooterActions: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
compactFooterButton: {
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
formGroup: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
formLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
formActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
codeBlockLoading: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
const MAIN_SNAP_POINTS = ["65%", "92%"];
|
||||
const ADD_SNAP_POINTS = ["40%"];
|
||||
const DIAGNOSTIC_SNAP_POINTS = ["50%", "85%"];
|
||||
@@ -1,480 +0,0 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import {
|
||||
ProviderInstallationSection,
|
||||
type ProviderInstallationState,
|
||||
type ProviderUpdateAction,
|
||||
type ProviderUpdatesState,
|
||||
} from "./provider-installation-section";
|
||||
|
||||
// Load translations so controls expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
const mounted: { root: Root; container: HTMLDivElement }[] = [];
|
||||
afterEach(() => {
|
||||
for (const entry of mounted.splice(0)) {
|
||||
act(() => entry.root.unmount());
|
||||
entry.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
const INSTALL_URL = "https://example.com/install";
|
||||
const UPDATE_URL = "https://example.com/update";
|
||||
|
||||
function mount(
|
||||
state: ProviderInstallationState,
|
||||
updates: ProviderUpdatesState = { status: "hidden" },
|
||||
action?: ProviderUpdateAction,
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() =>
|
||||
root.render(<ProviderInstallationSection state={state} updates={updates} action={action} />),
|
||||
);
|
||||
mounted.push({ root, container });
|
||||
const view = within(container);
|
||||
return {
|
||||
view,
|
||||
rerender: (nextUpdates: ProviderUpdatesState, nextAction?: ProviderUpdateAction) =>
|
||||
act(() =>
|
||||
root.render(
|
||||
<ProviderInstallationSection state={state} updates={nextUpdates} action={nextAction} />,
|
||||
),
|
||||
),
|
||||
installButton: () => view.queryByRole("button", { name: "Install" }),
|
||||
installLink: () => view.queryByRole("link", { name: "Install instructions" }),
|
||||
};
|
||||
}
|
||||
|
||||
function ready(installation: ProviderInstallationState & { status: "ready" }) {
|
||||
return installation;
|
||||
}
|
||||
|
||||
describe("install link visibility", () => {
|
||||
it("offers the Install button when the provider reports it is not installed", () => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: { installUrl: INSTALL_URL, inspection: { status: "not_installed" } },
|
||||
}),
|
||||
);
|
||||
expect(section.installButton()).not.toBeNull();
|
||||
expect(section.installLink()).toBeNull();
|
||||
});
|
||||
|
||||
it("offers a quiet install link when the installation check failed", async () => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
installUrl: INSTALL_URL,
|
||||
inspection: { status: "error", message: "version probe exploded" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect.element(section.view.getByText("version probe exploded")).toBeVisible();
|
||||
expect(section.installLink()).not.toBeNull();
|
||||
expect(section.installButton()).toBeNull();
|
||||
});
|
||||
|
||||
it("offers a quiet install link for a provider that only declares where to install it", () => {
|
||||
const section = mount(ready({ status: "ready", installation: { installUrl: INSTALL_URL } }));
|
||||
expect(section.installLink()).not.toBeNull();
|
||||
expect(section.installButton()).toBeNull();
|
||||
});
|
||||
|
||||
it("offers no install link once the provider reports it is installed", () => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
installUrl: INSTALL_URL,
|
||||
inspection: { status: "installed", version: "1" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(section.installLink()).toBeNull();
|
||||
expect(section.installButton()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("update check", () => {
|
||||
const installed = ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
installUrl: INSTALL_URL,
|
||||
updateUrl: UPDATE_URL,
|
||||
inspection: { status: "installed", version: "0.165.0" },
|
||||
},
|
||||
});
|
||||
|
||||
it("shows the reported versions and message as written, with Update instructions", async () => {
|
||||
const section = mount(installed, {
|
||||
status: "ready",
|
||||
updateCheck: {
|
||||
status: "available",
|
||||
versions: ["0.168.0", "nightly (unstable)"],
|
||||
message: "Restart to finish",
|
||||
},
|
||||
});
|
||||
await expect.element(section.view.getByText("Update available")).toBeVisible();
|
||||
expect(
|
||||
section.view.getAllByTestId("provider-updates-version").map((node) => node.textContent),
|
||||
).toEqual(["0.168.0", "nightly (unstable)"]);
|
||||
await expect.element(section.view.getByText("Restart to finish")).toBeVisible();
|
||||
await expect
|
||||
.element(section.view.getByRole("button", { name: "Update instructions" }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
const inconclusive = [
|
||||
{
|
||||
name: "unknown",
|
||||
updates: { status: "ready", updateCheck: { status: "unknown", message: "Offline" } },
|
||||
},
|
||||
{
|
||||
name: "a thrown or timed-out check",
|
||||
updates: { status: "ready", updateCheck: { status: "error", message: "Offline" } },
|
||||
},
|
||||
{ name: "a failed request", updates: { status: "error", message: "Offline" } },
|
||||
] as const;
|
||||
|
||||
it.each(inconclusive)("shows $name as inconclusive, never up to date", async ({ updates }) => {
|
||||
const section = mount(installed, updates);
|
||||
await expect.element(section.view.getByText("Couldn't check for updates")).toBeVisible();
|
||||
await expect.element(section.view.getByText("Offline")).toBeVisible();
|
||||
expect(section.view.queryByText("Up to date")).toBeNull();
|
||||
await expect
|
||||
.element(section.view.getByRole("button", { name: "Update instructions" }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it.each(inconclusive)(
|
||||
"still says $name when the provider offers no update",
|
||||
async ({ updates }) => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: { inspection: { status: "installed", version: "0.165.0" } },
|
||||
}),
|
||||
updates,
|
||||
);
|
||||
await expect.element(section.view.getByText("Couldn't check for updates")).toBeVisible();
|
||||
expect(section.view.queryByRole("button")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows up to date only when the provider says so", async () => {
|
||||
const section = mount(installed, { status: "ready", updateCheck: { status: "current" } });
|
||||
await expect.element(section.view.getByText("Up to date")).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows the check's answer for a provider that offers no update", async () => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: { inspection: { status: "installed", version: "1.4.18" } },
|
||||
}),
|
||||
{ status: "ready", updateCheck: { status: "current" } },
|
||||
);
|
||||
await expect.element(section.view.getByText("Up to date")).toBeVisible();
|
||||
expect(section.view.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "cannot check", updates: { status: "ready", updateCheck: null } },
|
||||
{ name: "is still checking", updates: { status: "loading" } },
|
||||
] as const)(
|
||||
"shows no Updates row while a provider that offers no update $name",
|
||||
({ updates }) => {
|
||||
const section = mount(
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
installUrl: INSTALL_URL,
|
||||
inspection: { status: "installed", version: "1.0.0" },
|
||||
},
|
||||
}),
|
||||
updates,
|
||||
);
|
||||
expect(section.view.queryByTestId("provider-updates")).toBeNull();
|
||||
expect(section.view.getByTestId("provider-installation-version").textContent).toBe("1.0.0");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("update action", () => {
|
||||
const installed = (canUpdate: boolean) =>
|
||||
ready({
|
||||
status: "ready",
|
||||
installation: {
|
||||
updateUrl: UPDATE_URL,
|
||||
inspection: { status: "installed", version: "1.0.0" },
|
||||
...(canUpdate ? { canUpdate: true } : {}),
|
||||
},
|
||||
});
|
||||
const available: ProviderUpdatesState = {
|
||||
status: "ready",
|
||||
updateCheck: { status: "available", versions: ["2.0.0"] },
|
||||
};
|
||||
function action(overrides: Partial<ProviderUpdateAction> = {}) {
|
||||
let starts = 0;
|
||||
const value: ProviderUpdateAction = {
|
||||
current: null,
|
||||
starting: false,
|
||||
startError: null,
|
||||
start: () => {
|
||||
starts += 1;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { value, starts: () => starts };
|
||||
}
|
||||
const operation = { id: "op", startedAt: "2026-11-13T00:00:00.000Z" };
|
||||
const finishedAt = "2026-11-13T00:01:00.000Z";
|
||||
|
||||
// The check only informs: Update and the update instructions never wait on it.
|
||||
const checkStates = [
|
||||
{ name: "an update is available", updates: available, says: "Update available" },
|
||||
// No check answer: the row shows only its title (asserted whole below).
|
||||
{
|
||||
name: "the provider cannot check",
|
||||
updates: { status: "ready", updateCheck: null },
|
||||
says: "Updates",
|
||||
},
|
||||
{ name: "the check is running", updates: { status: "loading" }, says: "Checking..." },
|
||||
{
|
||||
name: "the check could not tell",
|
||||
updates: { status: "ready", updateCheck: { status: "unknown" } },
|
||||
says: "Couldn't check for updates",
|
||||
},
|
||||
{
|
||||
name: "the check failed",
|
||||
updates: { status: "error", message: "Offline" },
|
||||
says: "Couldn't check for updates",
|
||||
},
|
||||
{
|
||||
name: "the provider is up to date",
|
||||
updates: { status: "ready", updateCheck: { status: "current" } },
|
||||
says: "Up to date",
|
||||
},
|
||||
] satisfies { name: string; updates: ProviderUpdatesState; says: string }[];
|
||||
|
||||
it.each(checkStates)(
|
||||
"offers Update when $name and the provider runs its own",
|
||||
async ({ updates, says }) => {
|
||||
const control = action();
|
||||
const section = mount(installed(true), updates, control.value);
|
||||
const button = section.view.getByRole("button", { name: "Update" });
|
||||
act(() => button.click());
|
||||
expect(control.starts()).toBe(1);
|
||||
expect(section.view.queryByRole("button", { name: "Update instructions" })).toBeNull();
|
||||
await expect.element(section.view.getByText(says, { exact: true })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(checkStates)(
|
||||
"offers Update instructions when $name and the provider only links its guide",
|
||||
async ({ updates }) => {
|
||||
const section = mount(installed(false), updates, action().value);
|
||||
expect(section.view.queryByRole("button", { name: "Update" })).toBeNull();
|
||||
await expect
|
||||
.element(section.view.getByRole("button", { name: "Update instructions" }))
|
||||
.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows no update status beside Update when the provider cannot check", () => {
|
||||
const section = mount(installed(true), { status: "ready", updateCheck: null }, action().value);
|
||||
expect(section.view.getByTestId("provider-updates").textContent).toBe("UpdatesUpdate");
|
||||
});
|
||||
|
||||
it("shows a running update with its output so far", () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
available,
|
||||
action({
|
||||
current: {
|
||||
operation: { ...operation, status: "running" },
|
||||
output: [
|
||||
{ type: "message", text: "Running codex update\n" },
|
||||
{ type: "stdout", text: "added 1 package\n" },
|
||||
],
|
||||
},
|
||||
}).value,
|
||||
);
|
||||
expect(section.view.queryByTestId("provider-update-status")).toBeNull();
|
||||
expect(section.view.getByTestId("provider-update-output").textContent).toBe(
|
||||
"Running codex update\nadded 1 package\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the provider's success message as written", () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
{ status: "ready", updateCheck: { status: "current" } },
|
||||
action({
|
||||
current: {
|
||||
operation: {
|
||||
...operation,
|
||||
status: "succeeded",
|
||||
finishedAt,
|
||||
message: " Restart agents \n",
|
||||
},
|
||||
output: [],
|
||||
},
|
||||
}).value,
|
||||
);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Updated");
|
||||
expect(section.view.getByTestId("provider-update-message").textContent).toBe(
|
||||
" Restart agents \n",
|
||||
);
|
||||
expect(section.view.queryByTestId("provider-update-output")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a failure with its message, its output and the update instructions", async () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
available,
|
||||
action({
|
||||
current: {
|
||||
operation: { ...operation, status: "failed", finishedAt, message: "exited with code 1" },
|
||||
output: [{ type: "stderr", text: "npm error EACCES\n" }],
|
||||
truncated: true,
|
||||
},
|
||||
}).value,
|
||||
);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
|
||||
await expect.element(section.view.getByText("exited with code 1")).toBeVisible();
|
||||
expect(section.view.getByTestId("provider-update-output").textContent).toBe(
|
||||
"npm error EACCES\n",
|
||||
);
|
||||
await expect.element(section.view.getByText("Earlier output was dropped.")).toBeVisible();
|
||||
await expect
|
||||
.element(section.view.getByRole("link", { name: "Update instructions" }))
|
||||
.toBeVisible();
|
||||
await expect.element(section.view.getByRole("button", { name: "Update" })).toBeVisible();
|
||||
});
|
||||
|
||||
// Update carries the update's progress from the tap until it settles, like every in-flight
|
||||
// button in Settings. The update's row shows only its output meanwhile.
|
||||
describe("progress", () => {
|
||||
const runningUpdate = (output: string[] = []) => ({
|
||||
operation: { ...operation, status: "running" as const },
|
||||
output: output.map((text) => ({ type: "stdout" as const, text })),
|
||||
});
|
||||
function progress(section: ReturnType<typeof mount>) {
|
||||
const indicators = section.view.queryAllByRole("progressbar");
|
||||
const button = section.view.queryByTestId("provider-update-start");
|
||||
return {
|
||||
count: indicators.length,
|
||||
inButton: indicators.every((indicator) => button?.contains(indicator) ?? false),
|
||||
label: button?.textContent,
|
||||
pressable: button?.getAttribute("aria-disabled") !== "true",
|
||||
};
|
||||
}
|
||||
const updating = { count: 1, inButton: true, label: "Updating...", pressable: false };
|
||||
const idle = { count: 0, inButton: true, label: "Update", pressable: true };
|
||||
|
||||
it("shows progress on Update alone from tap to settle", () => {
|
||||
const previous = {
|
||||
operation: { ...operation, status: "failed" as const, finishedAt, message: "exit 1" },
|
||||
output: [{ type: "stderr" as const, text: "old failure\n" }],
|
||||
};
|
||||
const section = mount(installed(true), available, action({ current: previous }).value);
|
||||
expect(progress(section)).toEqual(idle);
|
||||
|
||||
// Tapped, the host has not answered yet: the previous result gives way to the new update.
|
||||
section.rerender(available, action({ current: previous, starting: true }).value);
|
||||
expect(progress(section)).toEqual(updating);
|
||||
expect(section.view.queryByTestId("provider-update-run")).toBeNull();
|
||||
|
||||
// Running before any output arrives.
|
||||
section.rerender(available, action({ current: runningUpdate() }).value);
|
||||
expect(progress(section)).toEqual(updating);
|
||||
expect(section.view.queryByTestId("provider-update-run")).toBeNull();
|
||||
|
||||
section.rerender(available, action({ current: runningUpdate(["downloading\n"]) }).value);
|
||||
expect(progress(section)).toEqual(updating);
|
||||
expect(section.view.getByTestId("provider-update-run").textContent).toBe("downloading\n");
|
||||
|
||||
section.rerender(
|
||||
{ status: "ready", updateCheck: { status: "current" } },
|
||||
action({
|
||||
current: {
|
||||
...runningUpdate(["downloading\n"]),
|
||||
operation: { ...operation, status: "succeeded", finishedAt },
|
||||
},
|
||||
}).value,
|
||||
);
|
||||
expect(progress(section)).toEqual(idle);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Updated");
|
||||
});
|
||||
|
||||
it("shows progress on Update when the sheet reopens mid-update", () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
available,
|
||||
action({ current: runningUpdate(["downloading\n"]) }).value,
|
||||
);
|
||||
expect(progress(section)).toEqual(updating);
|
||||
expect(section.view.queryByTestId("provider-update-status")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "still loading", state: { status: "loading" } },
|
||||
{
|
||||
name: "not installed",
|
||||
state: { status: "ready", installation: { inspection: { status: "not_installed" } } },
|
||||
},
|
||||
{
|
||||
name: "failing",
|
||||
state: {
|
||||
status: "ready",
|
||||
installation: { inspection: { status: "error", message: "--version timed out" } },
|
||||
},
|
||||
},
|
||||
{ name: "unreachable", state: { status: "error", message: "Host disconnected" } },
|
||||
] satisfies { name: string; state: ProviderInstallationState }[])(
|
||||
"shows progress on Update while the installation reads as $name",
|
||||
({ state }) => {
|
||||
const section = mount(
|
||||
state,
|
||||
{ status: "hidden" },
|
||||
action({ current: runningUpdate() }).value,
|
||||
);
|
||||
// The inspection may show its own progress beside the Version row.
|
||||
const button = section.view.getByRole("button", { name: "Updating..." });
|
||||
expect(button.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(within(button).queryAllByRole("progressbar")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns Update to rest when the host refuses the start", () => {
|
||||
const section = mount(installed(true), available, action({ starting: true }).value);
|
||||
expect(progress(section)).toEqual(updating);
|
||||
section.rerender(available, action({ startError: "Busy" }).value);
|
||||
expect(progress(section)).toEqual(idle);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows why the host refused to start the update", async () => {
|
||||
const section = mount(
|
||||
installed(true),
|
||||
available,
|
||||
action({ startError: "Provider codex cannot update itself" }).value,
|
||||
);
|
||||
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
|
||||
await expect
|
||||
.element(section.view.getByText("Provider codex cannot update itself"))
|
||||
.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,448 +0,0 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type {
|
||||
ProviderInstallation,
|
||||
ProviderInstallationUpdate,
|
||||
ProviderInstallationUpdateCheck,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export type ProviderInstallationState =
|
||||
| { status: "unsupported" }
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; installation: ProviderInstallation | null }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
/**
|
||||
* What the provider's update check knows. `hidden` until the provider is installed. A null
|
||||
* `updateCheck` means the provider cannot check; the check only informs, and never gates Update.
|
||||
*/
|
||||
export type ProviderUpdatesState =
|
||||
| { status: "hidden" }
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; updateCheck: ProviderInstallationUpdateCheck | null }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
/** Running the provider's own update. Absent when the host cannot report updates. */
|
||||
export interface ProviderUpdateAction {
|
||||
/** The provider's latest update since the host started, or null. */
|
||||
current: ProviderInstallationUpdate | null;
|
||||
/** The request to start an update is in flight. */
|
||||
starting: boolean;
|
||||
/** Why the host did not start the update. */
|
||||
startError: string | null;
|
||||
start(): void;
|
||||
}
|
||||
|
||||
export function ProviderInstallationSection({
|
||||
state,
|
||||
updates,
|
||||
action,
|
||||
}: {
|
||||
state: ProviderInstallationState;
|
||||
updates: ProviderUpdatesState;
|
||||
action?: ProviderUpdateAction;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (state.status === "unsupported") return null;
|
||||
if (
|
||||
state.status === "ready" &&
|
||||
!state.installation?.inspection &&
|
||||
!state.installation?.installUrl
|
||||
)
|
||||
return null;
|
||||
const installation = state.status === "ready" ? state.installation : null;
|
||||
const run = action ? readUpdateRun(action) : null;
|
||||
// A running update keeps its row, so Update shows its progress whatever the inspection reads
|
||||
// meanwhile; the binary may be mid-replacement.
|
||||
const updating = action ? isUpdating(action) : false;
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section} testID="provider-installation">
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>
|
||||
{t("settings.providers.installation.title")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<InstallationRow state={state} />
|
||||
{installation?.inspection?.status === "installed" || updating ? (
|
||||
<UpdatesRow
|
||||
updates={updates}
|
||||
updateUrl={installation?.updateUrl}
|
||||
action={updating || installation?.canUpdate ? action : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{run ? <UpdateRunRow run={run} updateUrl={installation?.updateUrl} /> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallInstructionsLink({ installUrl }: { installUrl: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ExternalLink
|
||||
href={installUrl}
|
||||
label={t("settings.providers.installation.installInstructions")}
|
||||
testID="provider-installation-install-instructions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallationRow({ state }: { state: ProviderInstallationState }) {
|
||||
const { t } = useTranslation();
|
||||
const installation = state.status === "ready" ? state.installation : null;
|
||||
const installUrl = installation?.installUrl;
|
||||
const handleInstall = useCallback(() => {
|
||||
if (installUrl) void openExternalUrl(installUrl);
|
||||
}, [installUrl]);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.version")}</Text>
|
||||
<View style={styles.trailing}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.value}>{t("settings.providers.installation.checking")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const inspection = installation?.inspection;
|
||||
if (inspection?.status === "not_installed") {
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.providers.installation.notInstalled")}
|
||||
</Text>
|
||||
{installUrl ? (
|
||||
<Button variant="outline" onPress={handleInstall}>
|
||||
{t("settings.providers.installation.install")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (inspection?.status === "installed") {
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.version")}</Text>
|
||||
{inspection.version ? (
|
||||
<Text
|
||||
style={styles.version}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
testID="provider-installation-version"
|
||||
>
|
||||
{inspection.version}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={styles.value}>{t("settings.providers.installation.installed")}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// The installed state is unknown: the check failed, or the provider only links to a guide.
|
||||
let message: string | null = null;
|
||||
if (state.status === "error") message = state.message;
|
||||
else if (inspection?.status === "error") message = inspection.message;
|
||||
if (!message) {
|
||||
return installUrl ? (
|
||||
<View style={settingsStyles.row}>
|
||||
<InstallInstructionsLink installUrl={installUrl} />
|
||||
</View>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.failed")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{message}</Text>
|
||||
{installUrl ? (
|
||||
<View style={styles.link}>
|
||||
<InstallInstructionsLink installUrl={installUrl} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The update the provider offers, with what its check knows beside it. Update, or the update
|
||||
* instructions, stay in place whatever the check says: an answer can be an hour old, and the
|
||||
* provider's own updater reports when there is nothing newer.
|
||||
*/
|
||||
function UpdatesRow({
|
||||
updates,
|
||||
updateUrl,
|
||||
action,
|
||||
}: {
|
||||
updates: ProviderUpdatesState;
|
||||
updateUrl: string | undefined;
|
||||
/** Present when the provider runs its own update. */
|
||||
action: ProviderUpdateAction | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const updateCheck = updates.status === "ready" ? updates.updateCheck : null;
|
||||
const offersUpdate = Boolean(action || updateUrl);
|
||||
// With nothing to offer, the row waits until the check has something to say.
|
||||
if (!offersUpdate && !updateCheck && updates.status !== "error") return null;
|
||||
|
||||
return (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="provider-updates">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{updateCheck?.status === "available"
|
||||
? t("settings.providers.installation.updateAvailable")
|
||||
: t("settings.providers.installation.updates")}
|
||||
</Text>
|
||||
<UpdateCheckSummary updates={updates} />
|
||||
</View>
|
||||
<UpdateButton action={action} updateUrl={updateUrl} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateCheckSummary({ updates }: { updates: ProviderUpdatesState }) {
|
||||
const { t } = useTranslation();
|
||||
if (updates.status === "loading") {
|
||||
return (
|
||||
<View style={styles.trailing}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.providers.installation.checking")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (updates.status === "hidden") return null;
|
||||
const updateCheck = updates.status === "ready" ? updates.updateCheck : null;
|
||||
if (updates.status === "ready" && !updateCheck) return null;
|
||||
if (updateCheck?.status === "current") {
|
||||
return (
|
||||
<Text style={settingsStyles.rowHint} testID="provider-updates-current">
|
||||
{t("settings.providers.installation.upToDate")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (updateCheck?.status === "available") {
|
||||
// Versions are opaque; drop repeats so each one keys its own line.
|
||||
const versions = [...new Set(updateCheck.versions ?? [])];
|
||||
return (
|
||||
<>
|
||||
{versions.map((version) => (
|
||||
<Text
|
||||
key={version}
|
||||
style={styles.availableVersion}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
testID="provider-updates-version"
|
||||
>
|
||||
{version}
|
||||
</Text>
|
||||
))}
|
||||
{updateCheck.message ? (
|
||||
<Text style={settingsStyles.rowHint}>{updateCheck.message}</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
// A check that threw, timed out or could not tell is never shown as up to date.
|
||||
const message = updates.status === "error" ? updates.message : updateCheck?.message;
|
||||
return (
|
||||
<>
|
||||
<Text style={settingsStyles.rowHint} testID="provider-updates-unchecked">
|
||||
{t("settings.providers.installation.updatesFailed")}
|
||||
</Text>
|
||||
{message ? <Text style={settingsStyles.rowHint}>{message}</Text> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function isUpdating(action: ProviderUpdateAction): boolean {
|
||||
return action.starting || action.current?.operation.status === "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* Update runs the provider's updater; without one, the provider's update guide opens. The button
|
||||
* carries the update's progress from the tap until it settles.
|
||||
*/
|
||||
function UpdateButton({
|
||||
action,
|
||||
updateUrl,
|
||||
}: {
|
||||
action: ProviderUpdateAction | undefined;
|
||||
updateUrl: string | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleOpenInstructions = useCallback(() => {
|
||||
if (updateUrl) void openExternalUrl(updateUrl);
|
||||
}, [updateUrl]);
|
||||
if (action)
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={action.start}
|
||||
loading={isUpdating(action)}
|
||||
testID="provider-update-start"
|
||||
>
|
||||
{isUpdating(action)
|
||||
? t("settings.providers.installation.updating")
|
||||
: t("settings.providers.installation.update")}
|
||||
</Button>
|
||||
);
|
||||
if (!updateUrl) return null;
|
||||
return (
|
||||
<Button variant="outline" onPress={handleOpenInstructions}>
|
||||
{t("settings.providers.installation.updateInstructions")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type UpdateRun =
|
||||
| { status: "running"; update: ProviderInstallationUpdate }
|
||||
| { status: "succeeded" | "failed"; update: ProviderInstallationUpdate }
|
||||
| { status: "refused"; message: string };
|
||||
|
||||
/**
|
||||
* What the update's row shows, or null for nothing. While the update starts or runs, Update shows
|
||||
* its progress and the row holds only the output so far. A new start clears the previous result.
|
||||
*/
|
||||
function readUpdateRun(action: ProviderUpdateAction): UpdateRun | null {
|
||||
const update = action.current;
|
||||
if (isUpdating(action)) {
|
||||
return update?.operation.status === "running" && update.output.length > 0
|
||||
? { status: "running", update }
|
||||
: null;
|
||||
}
|
||||
if (action.startError) return { status: "refused", message: action.startError };
|
||||
if (!update || update.operation.status === "running") return null;
|
||||
return { status: update.operation.status, update };
|
||||
}
|
||||
|
||||
function UpdateRunRow({ run, updateUrl }: { run: UpdateRun; updateUrl: string | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const scroll = useRef<ScrollView>(null);
|
||||
const followOutput = useCallback(() => scroll.current?.scrollToEnd({ animated: false }), []);
|
||||
const update = run.status === "refused" ? null : run.update;
|
||||
const output = update?.output.map((entry) => entry.text).join("") ?? "";
|
||||
let message: string | undefined;
|
||||
if (run.status === "refused") message = run.message;
|
||||
else if (run.update.operation.status !== "running") message = run.update.operation.message;
|
||||
|
||||
return (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="provider-update-run">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
{run.status === "running" ? null : (
|
||||
<Text style={settingsStyles.rowTitle} testID="provider-update-status">
|
||||
{run.status === "succeeded"
|
||||
? t("settings.providers.installation.updated")
|
||||
: t("settings.providers.installation.updateFailed")}
|
||||
</Text>
|
||||
)}
|
||||
{message ? (
|
||||
<Text style={settingsStyles.rowHint} selectable testID="provider-update-message">
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
{output ? (
|
||||
<ScrollView
|
||||
ref={scroll}
|
||||
style={
|
||||
run.status === "running" ? styles.output : [styles.output, styles.outputAfterResult]
|
||||
}
|
||||
contentContainerStyle={styles.outputContent}
|
||||
onContentSizeChange={followOutput}
|
||||
// Android scrolls a ScrollView nested in the provider sheet only with this flag.
|
||||
nestedScrollEnabled
|
||||
>
|
||||
{update?.truncated ? (
|
||||
<Text style={styles.outputNote}>
|
||||
{t("settings.providers.installation.outputTruncated")}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={styles.outputText}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
testID="provider-update-output"
|
||||
>
|
||||
{output}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{(run.status === "failed" || run.status === "refused") && updateUrl ? (
|
||||
<View style={styles.link}>
|
||||
<ExternalLink
|
||||
href={updateUrl}
|
||||
label={t("settings.providers.installation.updateInstructions")}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
output: {
|
||||
maxHeight: 240,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
outputAfterResult: {
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
outputContent: {
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
outputText: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: 20,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
outputNote: {
|
||||
marginBottom: theme.spacing[1],
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
value: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
version: {
|
||||
flexShrink: 1,
|
||||
marginLeft: theme.spacing[3],
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
availableVersion: {
|
||||
marginTop: theme.spacing[1],
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
link: {
|
||||
flexDirection: "row",
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ProviderInstallationUpdate } from "@getpaseo/protocol/messages";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import type {
|
||||
ProviderInstallationState,
|
||||
ProviderUpdateAction,
|
||||
ProviderUpdatesState,
|
||||
} from "./provider-installation-section";
|
||||
import { applyProviderUpdateProgress } from "./provider-update-progress";
|
||||
|
||||
export interface ProviderInstallationRead {
|
||||
state: ProviderInstallationState;
|
||||
updates: ProviderUpdatesState;
|
||||
/** Absent when the host cannot report provider installations. */
|
||||
action: ProviderUpdateAction | undefined;
|
||||
/** Ask the provider again, skipping the host's caches. */
|
||||
refresh: () => void;
|
||||
/** A refresh is in flight. The last answers stay on screen until the new ones arrive. */
|
||||
refreshing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a provider's installation from hosts that advertise `providerInstallation`. The update
|
||||
* check is a separate request that starts once the provider reports itself installed, so the
|
||||
* installed version never waits on it.
|
||||
*/
|
||||
export function useProviderInstallation(input: {
|
||||
serverId: string;
|
||||
provider: string;
|
||||
visible: boolean;
|
||||
}): ProviderInstallationRead {
|
||||
const { serverId, provider, visible } = input;
|
||||
const supported = useHostFeature(serverId, "providerInstallation");
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const enabled = supported && visible && Boolean(client);
|
||||
const installationKey = ["provider-installation", serverId, provider] as const;
|
||||
const updatesKey = ["provider-updates", serverId, provider] as const;
|
||||
|
||||
const inspect = useCallback(
|
||||
async (refresh: boolean) => {
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const result = await client.inspectProviderInstallation(provider, { refresh });
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.installation;
|
||||
},
|
||||
[client, provider, t],
|
||||
);
|
||||
const checkForUpdates = useCallback(
|
||||
async (refresh: boolean) => {
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const result = await client.checkProviderUpdates(provider, { refresh });
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.updateCheck;
|
||||
},
|
||||
[client, provider, t],
|
||||
);
|
||||
|
||||
// The host caches both answers, so reading on every open is cheap.
|
||||
const installationQuery = useFetchQuery({
|
||||
queryKey: installationKey,
|
||||
queryFn: () => inspect(false),
|
||||
enabled,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
const installed = installationQuery.data?.inspection?.status === "installed";
|
||||
const updatesQuery = useFetchQuery({
|
||||
queryKey: updatesKey,
|
||||
queryFn: () => checkForUpdates(false),
|
||||
enabled: enabled && installed,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
|
||||
// A refresh runs through the queries so its answer or its failure is what the screen shows.
|
||||
// `staleTime: 0` overrides the app default so the request always goes out.
|
||||
const refreshInstallation = useMutation({
|
||||
mutationFn: () =>
|
||||
queryClient.fetchQuery({
|
||||
queryKey: installationKey,
|
||||
queryFn: () => inspect(true),
|
||||
staleTime: 0,
|
||||
}),
|
||||
});
|
||||
const refreshUpdates = useMutation({
|
||||
mutationFn: () =>
|
||||
queryClient.fetchQuery({
|
||||
queryKey: updatesKey,
|
||||
queryFn: () => checkForUpdates(true),
|
||||
staleTime: 0,
|
||||
}),
|
||||
});
|
||||
// The host owns the update; this view follows it while open and catches up on reopen.
|
||||
const [currentUpdate, setCurrentUpdate] = useState<ProviderInstallationUpdate | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled || !client) return;
|
||||
const subscription = client.observeProviderUpdates(provider);
|
||||
// The update this view saw running. Its success arrives as live progress, or as the snapshot
|
||||
// a reconnect delivers when the progress was missed; either way the host dropped its cached
|
||||
// answers, so read the new version and update state.
|
||||
let runningId: string | null = null;
|
||||
const follow = (operation: ProviderInstallationUpdate["operation"] | undefined) => {
|
||||
if (!operation) return;
|
||||
if (operation.status === "running") {
|
||||
runningId = operation.id;
|
||||
return;
|
||||
}
|
||||
if (operation.id !== runningId) return;
|
||||
runningId = null;
|
||||
if (operation.status !== "succeeded") return;
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["provider-installation", serverId, provider],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["provider-updates", serverId, provider] });
|
||||
};
|
||||
const detach = subscription.subscribe({
|
||||
snapshot: (response) => {
|
||||
setCurrentUpdate(response.update);
|
||||
follow(response.update?.operation);
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "provider.installation.update.progress") return;
|
||||
setCurrentUpdate((current) => applyProviderUpdateProgress(current, message.payload));
|
||||
follow(message.payload.operation);
|
||||
},
|
||||
error: (failure) => console.warn("Failed to observe provider updates", failure),
|
||||
});
|
||||
return () => {
|
||||
detach();
|
||||
void subscription
|
||||
.release()
|
||||
.catch((failure) => console.warn("Failed to release provider update observation", failure));
|
||||
};
|
||||
}, [client, enabled, provider, queryClient, serverId]);
|
||||
const startUpdate = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
const result = await client.updateProvider(provider);
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.update;
|
||||
},
|
||||
onSuccess: (update) => {
|
||||
if (!update) return;
|
||||
// Progress for this operation may already have arrived; keep the further-along copy.
|
||||
setCurrentUpdate((current) =>
|
||||
current?.operation.id === update.operation.id ? current : update,
|
||||
);
|
||||
},
|
||||
});
|
||||
const { mutate: mutateStartUpdate } = startUpdate;
|
||||
const start = useCallback(() => mutateStartUpdate(), [mutateStartUpdate]);
|
||||
const action: ProviderUpdateAction | undefined = supported
|
||||
? {
|
||||
current: currentUpdate,
|
||||
starting: startUpdate.isPending,
|
||||
startError: startUpdate.error?.message ?? null,
|
||||
start,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const { mutate: mutateInstallation } = refreshInstallation;
|
||||
const { mutate: mutateUpdates } = refreshUpdates;
|
||||
const refresh = useCallback(() => {
|
||||
if (!supported || !client) return;
|
||||
mutateInstallation();
|
||||
if (installed) mutateUpdates();
|
||||
}, [client, installed, mutateInstallation, mutateUpdates, supported]);
|
||||
|
||||
let state: ProviderInstallationState;
|
||||
if (!supported) state = { status: "unsupported" };
|
||||
else if (installationQuery.isPending) state = { status: "loading" };
|
||||
else if (installationQuery.isError)
|
||||
state = { status: "error", message: installationQuery.error.message };
|
||||
else state = { status: "ready", installation: installationQuery.data ?? null };
|
||||
|
||||
let updates: ProviderUpdatesState;
|
||||
if (!supported || !installed) updates = { status: "hidden" };
|
||||
else if (updatesQuery.isPending) updates = { status: "loading" };
|
||||
else if (updatesQuery.isError) updates = { status: "error", message: updatesQuery.error.message };
|
||||
else updates = { status: "ready", updateCheck: updatesQuery.data ?? null };
|
||||
|
||||
const refreshing = refreshInstallation.isPending || refreshUpdates.isPending;
|
||||
return { state, updates, action, refresh, refreshing };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { ProviderDetailSheet } from "@/provider-settings";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { OverlayLayerProvider } from "@/lib/overlay-root";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ProviderSettingsHost() {
|
||||
|
||||
return (
|
||||
<OverlayLayerProvider layer={overlayParentLayer}>
|
||||
<ProviderDetailSheet
|
||||
<ProviderDiagnosticSheet
|
||||
key={`${serverId}:${provider}`}
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProviderInstallationUpdateProgress } from "@getpaseo/protocol/messages";
|
||||
import { applyProviderUpdateProgress } from "./provider-update-progress";
|
||||
|
||||
const running = {
|
||||
status: "running",
|
||||
id: "update-1",
|
||||
startedAt: "2026-11-13T00:00:00.000Z",
|
||||
} as const;
|
||||
|
||||
function progress(...texts: string[]): ProviderInstallationUpdateProgress {
|
||||
return {
|
||||
provider: "codex",
|
||||
operation: running,
|
||||
output: texts.map((text) => ({ type: "stdout", text })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyProviderUpdateProgress", () => {
|
||||
it("appends output for the same update", () => {
|
||||
const first = applyProviderUpdateProgress(null, progress("a"), { outputLimit: 10 });
|
||||
const second = applyProviderUpdateProgress(first, progress("b"), { outputLimit: 10 });
|
||||
expect(second.output.map((entry) => entry.text)).toEqual(["a", "b"]);
|
||||
expect(second.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops the oldest output beyond the limit and marks the update truncated", () => {
|
||||
let update = applyProviderUpdateProgress(null, progress("12345"), { outputLimit: 8 });
|
||||
update = applyProviderUpdateProgress(update, progress("6789"), { outputLimit: 8 });
|
||||
expect(update.output.map((entry) => entry.text)).toEqual(["6789"]);
|
||||
expect(update.truncated).toBe(true);
|
||||
|
||||
update = applyProviderUpdateProgress(update, progress("abcdefghij"), { outputLimit: 8 });
|
||||
expect(update.output.map((entry) => entry.text)).toEqual(["cdefghij"]);
|
||||
expect(update.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("starts a new update empty", () => {
|
||||
const first = applyProviderUpdateProgress(null, progress("123456789"), { outputLimit: 4 });
|
||||
const next = applyProviderUpdateProgress(
|
||||
first,
|
||||
{ ...progress("x"), operation: { ...running, id: "update-2" } },
|
||||
{ outputLimit: 4 },
|
||||
);
|
||||
expect(next.output.map((entry) => entry.text)).toEqual(["x"]);
|
||||
expect(next.truncated).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import type {
|
||||
ProviderInstallationUpdate,
|
||||
ProviderInstallationUpdateOutput,
|
||||
ProviderInstallationUpdateProgress,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
/** Characters of update output the app keeps, matching what the daemon retains. */
|
||||
export const PROVIDER_UPDATE_OUTPUT_LIMIT = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Folds a progress message into the update it belongs to; a new operation starts empty. Output
|
||||
* beyond `outputLimit` characters drops oldest first and marks the update truncated, so a noisy
|
||||
* updater cannot grow the screen's memory while it stays open.
|
||||
*/
|
||||
export function applyProviderUpdateProgress(
|
||||
current: ProviderInstallationUpdate | null,
|
||||
progress: ProviderInstallationUpdateProgress,
|
||||
options: { outputLimit: number } = { outputLimit: PROVIDER_UPDATE_OUTPUT_LIMIT },
|
||||
): ProviderInstallationUpdate {
|
||||
const previous = current?.operation.id === progress.operation.id ? current : null;
|
||||
const limit = options.outputLimit;
|
||||
const output: ProviderInstallationUpdateOutput[] = [
|
||||
...(previous?.output ?? []),
|
||||
...progress.output,
|
||||
];
|
||||
let length = output.reduce((total, entry) => total + entry.text.length, 0);
|
||||
let truncated = previous?.truncated ?? false;
|
||||
while (length > limit && output.length > 1) {
|
||||
length -= output.shift()!.text.length;
|
||||
truncated = true;
|
||||
}
|
||||
const last = output.at(-1);
|
||||
if (last && last.text.length > limit) {
|
||||
output[output.length - 1] = { type: last.type, text: last.text.slice(-limit) };
|
||||
truncated = true;
|
||||
}
|
||||
return { operation: progress.operation, output, ...(truncated ? { truncated: true } : {}) };
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { useCallback } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import { useSettingsRowLayout } from "./row-density";
|
||||
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
|
||||
type HoverState = PressableStateCallbackType & { hovered?: boolean };
|
||||
|
||||
/**
|
||||
* The last row of a card that lists things, adding one more. Quiet, so the list reads first: a
|
||||
* muted plus in the rows' leading slot and a muted label on the labels' rail.
|
||||
*/
|
||||
export function SettingsAddRow({
|
||||
label,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
const layout = useSettingsRowLayout();
|
||||
const rowStyle = useCallback(
|
||||
({ hovered, pressed }: HoverState) => [
|
||||
layout.row,
|
||||
styles.row,
|
||||
hovered && styles.hovered,
|
||||
pressed && styles.pressed,
|
||||
],
|
||||
[layout],
|
||||
);
|
||||
return (
|
||||
<PressHighlight
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.pressed}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.leading}>
|
||||
<ThemedPlus size={ICON_SIZE.md} uniProps={mutedIconColorMapping} />
|
||||
</View>
|
||||
<Text style={[layout.title, styles.label]}>{label}</Text>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: { justifyContent: "flex-start" },
|
||||
leading: { marginRight: theme.spacing[3], alignItems: "center", justifyContent: "center" },
|
||||
label: { color: theme.colors.foregroundMuted },
|
||||
hovered: { backgroundColor: theme.colors.surface2 },
|
||||
pressed: { backgroundColor: theme.colors.surface3 },
|
||||
}));
|
||||
@@ -1,86 +0,0 @@
|
||||
import { ArrowLeft } from "lucide-react-native";
|
||||
import type { ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* "Back to …" above a settings detail page on wide layouts, where the page has no header of its
|
||||
* own. The ghost button drops its padding so the arrow sits on the content's leading rail.
|
||||
*/
|
||||
export function SettingsBackLink({
|
||||
label,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
testID={testID}
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
variant="ghost"
|
||||
leftIcon={ArrowLeft}
|
||||
style={styles.backLink}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The top of a settings detail page: "Back to …" when the page has no header of its own, then the
|
||||
* icon and name the page is about, with an optional action after the name.
|
||||
*/
|
||||
export function SettingsDetailHeader({
|
||||
back,
|
||||
icon,
|
||||
title,
|
||||
trailing,
|
||||
titleTestID,
|
||||
}: {
|
||||
back?: { label: string; onPress: () => void; testID?: string };
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
trailing?: ReactNode;
|
||||
titleTestID?: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{back ? <SettingsBackLink {...back} /> : null}
|
||||
<View style={styles.titleRow}>
|
||||
{icon}
|
||||
<Text style={styles.title} numberOfLines={1} testID={titleTestID}>
|
||||
{title}
|
||||
</Text>
|
||||
{trailing}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
backLink: {
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
header: {
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
titleRow: {
|
||||
marginTop: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
@@ -1,16 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SettingsRowHeader, type SettingsRowHintStatus } from "./row-header";
|
||||
import { SettingsRowHeader } from "./row-header";
|
||||
|
||||
interface SettingsDetailRowProps {
|
||||
label: string;
|
||||
/** An icon before the label, such as the thing the row opens. */
|
||||
leading?: ReactNode;
|
||||
/** Shown beside the label, such as a badge. */
|
||||
labelAccessory?: ReactNode;
|
||||
/** A line under the label saying what the row leads to. */
|
||||
hint?: string;
|
||||
/** Marks the hint as work under way, a fault, or a value still loading. */
|
||||
hintStatus?: SettingsRowHintStatus;
|
||||
/** What the detail is set to, so the row says it without being opened. */
|
||||
summary?: string;
|
||||
onPress: () => void;
|
||||
@@ -18,10 +14,6 @@ interface SettingsDetailRowProps {
|
||||
disabled?: boolean;
|
||||
/** Opens a page outside the app; an outward arrow replaces the chevron. */
|
||||
external?: boolean;
|
||||
/** A control acting on the row's subject in place, such as a switch, before the chevron. */
|
||||
control?: ReactNode;
|
||||
/** Names the row for assistive tech when the label alone does not say where it leads. */
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,16 +30,11 @@ export { SettingsGroup } from "./headings/settings-group";
|
||||
export { SettingsSection } from "./headings/settings-section";
|
||||
export { SettingsCollapsibleRow } from "./collapsible-row";
|
||||
export { SettingsDetailRow } from "./detail-row";
|
||||
export type { SettingsRowHintStatus } from "./row-header";
|
||||
export { SettingsAddRow } from "./add-row";
|
||||
export { SettingsBackLink, SettingsDetailHeader } from "./detail-header";
|
||||
export { SettingsInfoTip } from "./headings/settings-info-tip";
|
||||
|
||||
/** What app screens can add to a plugin-facing row: an accessory beside the label, such as an info tip. */
|
||||
interface AppRowExtras {
|
||||
labelAccessory?: ReactNode;
|
||||
/** An icon before the label, such as the thing the row is about. */
|
||||
leading?: ReactNode;
|
||||
}
|
||||
|
||||
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint">, AppRowExtras {
|
||||
@@ -63,7 +58,6 @@ export function SettingsCard({ children, testID }: { children: ReactNode; testID
|
||||
|
||||
export function SettingsRow({
|
||||
label,
|
||||
leading,
|
||||
labelAccessory,
|
||||
hint,
|
||||
error,
|
||||
@@ -76,7 +70,6 @@ export function SettingsRow({
|
||||
const accessoryTitleStyle = useMemo(() => [layout.title, styles.accessoryLabel], [layout]);
|
||||
return (
|
||||
<View style={rowStyle} testID={testID}>
|
||||
{leading ? <View style={styles.leading}>{leading}</View> : null}
|
||||
<View style={styles.label}>
|
||||
{labelAccessory ? (
|
||||
<View style={styles.labelRow}>
|
||||
@@ -214,11 +207,7 @@ export function SettingsAction({ actionLabel, onPress, disabled, ...row }: Setti
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// A control that wraps under the label gets space above it. Across the row, the leading slot and
|
||||
// the label already carry their own margins, so a column gap would double them.
|
||||
compactRow: { flexWrap: "wrap", rowGap: theme.spacing[3] },
|
||||
// The same leading slot as `SettingsRowHeader`, so icons and labels share rails across row kinds.
|
||||
leading: { marginRight: theme.spacing[3], alignItems: "center", justifyContent: "center" },
|
||||
compactRow: { flexWrap: "wrap", gap: theme.spacing[3] },
|
||||
label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] },
|
||||
labelRow: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -2,12 +2,7 @@ import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCollapsibleRow,
|
||||
SettingsDetailRow,
|
||||
SettingsRow,
|
||||
} from "@/components/settings";
|
||||
import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings";
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
@@ -19,8 +14,6 @@ afterEach(() => {
|
||||
container = null;
|
||||
});
|
||||
|
||||
function openNothing() {}
|
||||
|
||||
function mountRows() {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -33,7 +26,6 @@ function mountRows() {
|
||||
Download
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
<SettingsDetailRow label="Opens a page" onPress={openNothing} testID="detail-row" />
|
||||
<SettingsCollapsibleRow label="Group" defaultExpanded testID="group">
|
||||
<SettingsRow label="Nested with a button" testID="nested-with-button">
|
||||
<Button variant="outline" testID="nested-button">
|
||||
@@ -74,13 +66,6 @@ describe("settings row density", () => {
|
||||
expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button"));
|
||||
});
|
||||
|
||||
it("keeps a row that opens something as tall as a row holding a control", () => {
|
||||
mountRows();
|
||||
|
||||
expect(heightOf("detail-row")).toBe(heightOf("card-row"));
|
||||
expect(heightOf("group-toggle")).toBe(heightOf("card-row"));
|
||||
});
|
||||
|
||||
it("sizes nested text and controls one step below card rows", () => {
|
||||
mountRows();
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ export function NestedSettingsRows({ children }: { children: ReactNode }) {
|
||||
|
||||
export interface SettingsRowLayout {
|
||||
row: StyleProp<ViewStyle>;
|
||||
/** A row as tall as one holding a control of `controlSize`, whether or not it holds one. */
|
||||
controlRow: StyleProp<ViewStyle>;
|
||||
title: StyleProp<TextStyle>;
|
||||
hint: StyleProp<TextStyle>;
|
||||
/** The size of the controls in the row's trailing slot. */
|
||||
@@ -32,14 +30,12 @@ export function useSettingsRowLayout(): SettingsRowLayout {
|
||||
nested
|
||||
? {
|
||||
row: [settingsStyles.row, styles.nestedRow],
|
||||
controlRow: [settingsStyles.row, styles.nestedRow],
|
||||
title: [settingsStyles.rowTitle, styles.nestedText],
|
||||
hint: [settingsStyles.rowHint, styles.nestedText],
|
||||
controlSize: "sm",
|
||||
}
|
||||
: {
|
||||
row: settingsStyles.row,
|
||||
controlRow: [settingsStyles.row, styles.cardControlRow],
|
||||
title: settingsStyles.rowTitle,
|
||||
hint: settingsStyles.rowHint,
|
||||
controlSize: "md",
|
||||
@@ -49,9 +45,6 @@ export function useSettingsRowLayout(): SettingsRowLayout {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
cardControlRow: {
|
||||
minHeight: mapLayout(CONTROL_HEIGHTS.md, (height) => height + theme.spacing[4] * 2),
|
||||
},
|
||||
// Every nested row is as tall as one holding an `sm` control, so rows with and without a
|
||||
// trailing button line up.
|
||||
nestedRow: {
|
||||
|
||||
@@ -1,36 +1,25 @@
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ControlSizeProvider } from "@/components/ui/control-size";
|
||||
import { ExternalLinkLabel } from "@/components/ui/external-link";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { useSettingsRowLayout, type SettingsRowLayout } from "./row-density";
|
||||
import { useSettingsRowLayout } from "./row-density";
|
||||
|
||||
const ThemedChevron = withUnistyles(ChevronRight);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
/**
|
||||
* What the hint reports beyond its words: work under way (a leading spinner), a fault (a leading
|
||||
* red dot and red text), or a value still loading (a placeholder the height of the line).
|
||||
*/
|
||||
export type SettingsRowHintStatus = "busy" | "error" | "pending";
|
||||
type HoverState = PressableStateCallbackType & { hovered?: boolean };
|
||||
|
||||
export interface SettingsRowHeaderProps {
|
||||
label: string;
|
||||
/** An icon before the label, such as the thing the row opens. */
|
||||
leading?: ReactNode;
|
||||
/** Shown beside the label, such as a badge. */
|
||||
labelAccessory?: ReactNode;
|
||||
/** A line under the label saying what the row leads to. */
|
||||
hint?: string;
|
||||
hintStatus?: SettingsRowHintStatus;
|
||||
/** A short status shown before the chevron. */
|
||||
summary?: string;
|
||||
onPress: () => void;
|
||||
@@ -39,10 +28,6 @@ export interface SettingsRowHeaderProps {
|
||||
expanded?: boolean;
|
||||
/** The row leaves the app, such as to a docs page: the summary and an outward arrow are drawn as an external link in place of the chevron. */
|
||||
external?: boolean;
|
||||
/** A control acting on the row's subject in place, such as a switch, before the chevron. */
|
||||
control?: ReactNode;
|
||||
/** Names the row for assistive tech when the label alone does not say where it leads. */
|
||||
accessibilityLabel?: string;
|
||||
accessibilityState?: AccessibilityState;
|
||||
/** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */
|
||||
webAccessibilityProps?: Record<string, unknown> | null;
|
||||
@@ -53,216 +38,81 @@ export interface SettingsRowHeaderProps {
|
||||
* The pressable line shared by settings rows that lead somewhere: a label, an optional summary and
|
||||
* a trailing chevron. A collapsible row turns the chevron down; a row that opens a sheet keeps it
|
||||
* pointing right.
|
||||
*
|
||||
* The press target is a button laid under the row's content rather than wrapped around it, so a
|
||||
* control in the row is the button's sibling: a switch inside a button is invalid markup, and keys
|
||||
* pressed on the switch would also press the row. Everything drawn over the button lets presses
|
||||
* through to it. The label, icon and summary stay out of the accessibility tree, where the button's
|
||||
* label already names the row; the hint stays readable, since it carries the row's state.
|
||||
* Hover is tracked on the row itself, so it holds while the pointer is over the control.
|
||||
*
|
||||
* The row is as tall as one holding a control whether or not it holds one, so a list where only
|
||||
* some rows carry a switch keeps one row height.
|
||||
*/
|
||||
export function SettingsRowHeader({
|
||||
label,
|
||||
leading,
|
||||
labelAccessory,
|
||||
hint,
|
||||
hintStatus,
|
||||
summary,
|
||||
onPress,
|
||||
disabled,
|
||||
expanded = false,
|
||||
external = false,
|
||||
control,
|
||||
accessibilityLabel,
|
||||
accessibilityState,
|
||||
webAccessibilityProps,
|
||||
testID,
|
||||
}: SettingsRowHeaderProps) {
|
||||
const layout = useSettingsRowLayout();
|
||||
const [pointerOver, setPointerOver] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setPointerOver(true), []);
|
||||
const handlePointerLeave = useCallback(() => setPointerOver(false), []);
|
||||
const hovered = pointerOver && !disabled;
|
||||
const rowStyle = useMemo(() => [layout.controlRow, hovered && styles.hovered], [hovered, layout]);
|
||||
const pressTargetStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
styles.pressTarget,
|
||||
!disabled && pressed && styles.pressed,
|
||||
],
|
||||
[disabled],
|
||||
const rowStyle = useCallback(
|
||||
({ hovered }: HoverState) => [layout.row, !disabled && hovered && styles.hovered],
|
||||
[disabled, layout],
|
||||
);
|
||||
const chevronStyle = useMemo(
|
||||
() => [styles.chevron, expanded && styles.chevronExpanded],
|
||||
[expanded],
|
||||
);
|
||||
return (
|
||||
<View
|
||||
<PressHighlight
|
||||
{...webAccessibilityProps}
|
||||
accessibilityRole={external ? "link" : "button"}
|
||||
accessibilityLabel={summary ? `${label}, ${summary}` : label}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={rowStyle}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
highlightStyle={styles.pressed}
|
||||
testID={testID}
|
||||
>
|
||||
<PressHighlight
|
||||
{...webAccessibilityProps}
|
||||
accessibilityRole={external ? "link" : "button"}
|
||||
accessibilityLabel={accessibilityLabel ?? (summary ? `${label}, ${summary}` : label)}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={pressTargetStyle}
|
||||
highlightStyle={styles.pressed}
|
||||
/>
|
||||
{leading ? (
|
||||
<View style={[styles.leading, styles.passThrough]} {...HIDDEN_FROM_ASSISTIVE_TECH}>
|
||||
{leading}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[settingsStyles.rowContent, styles.passThrough]}>
|
||||
<View style={labelAccessory ? styles.labelRow : undefined} {...HIDDEN_FROM_ASSISTIVE_TECH}>
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
{labelAccessory}
|
||||
</View>
|
||||
{hintStatus ? (
|
||||
<RowHintLine hint={hint} status={hintStatus} hintStyle={layout.hint} />
|
||||
) : null}
|
||||
{hint && !hintStatus ? <Text style={layout.hint}>{hint}</Text> : null}
|
||||
</View>
|
||||
{control ? (
|
||||
<View style={styles.control}>
|
||||
<ControlSizeProvider size={layout.controlSize}>{control}</ControlSizeProvider>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[styles.trailing, styles.passThrough]} {...HIDDEN_FROM_ASSISTIVE_TECH}>
|
||||
{external ? (
|
||||
// Drawn the same as a section header's docs link, so the two read as one thing.
|
||||
<ExternalLinkLabel hovered={hovered}>{summary}</ExternalLinkLabel>
|
||||
) : (
|
||||
<>
|
||||
{summary ? (
|
||||
<Text style={styles.summary} numberOfLines={1}>
|
||||
{summary}
|
||||
</Text>
|
||||
) : null}
|
||||
{disabled ? null : (
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevron
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
|
||||
/>
|
||||
{({ hovered }: HoverState) => (
|
||||
<>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
{labelAccessory ? (
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
{labelAccessory}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const HIDDEN_FROM_ASSISTIVE_TECH = {
|
||||
accessibilityElementsHidden: true,
|
||||
importantForAccessibility: "no-hide-descendants",
|
||||
"aria-hidden": true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The hint with its status mark. The line keeps the hint's own height in every status, so a row
|
||||
* does not move when its value arrives or its work starts. A long error is cut to the line; the
|
||||
* row's detail says it in full.
|
||||
*/
|
||||
function RowHintLine({
|
||||
hint,
|
||||
status,
|
||||
hintStyle,
|
||||
}: {
|
||||
hint: string | undefined;
|
||||
status: SettingsRowHintStatus;
|
||||
hintStyle: SettingsRowLayout["hint"];
|
||||
}) {
|
||||
const textStyle = useMemo(
|
||||
() => [hintStyle, styles.hintText, status === "error" && styles.hintError],
|
||||
[hintStyle, status],
|
||||
);
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<View style={styles.hintLine}>
|
||||
{/* A blank line of hint text holds the height; the placeholder sits over it. */}
|
||||
<Text style={textStyle}> </Text>
|
||||
<HintPlaceholder />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.hintLine}>
|
||||
{status === "busy" ? (
|
||||
<ThemedLoadingSpinner size={14} uniProps={mutedIconColorMapping} />
|
||||
) : (
|
||||
<View style={styles.errorDot} />
|
||||
{hint ? <Text style={layout.hint}>{hint}</Text> : null}
|
||||
</View>
|
||||
{external ? (
|
||||
// Drawn the same as a section header's docs link, so the two read as one thing.
|
||||
<ExternalLinkLabel hovered={hovered}>{summary}</ExternalLinkLabel>
|
||||
) : (
|
||||
<>
|
||||
{summary ? (
|
||||
<Text style={styles.summary} numberOfLines={1}>
|
||||
{summary}
|
||||
</Text>
|
||||
) : null}
|
||||
{disabled ? null : (
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevron
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Text style={textStyle} numberOfLines={1}>
|
||||
{hint}
|
||||
</Text>
|
||||
</View>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
function HintPlaceholder() {
|
||||
const pulse = useSkeletonPulse();
|
||||
return <SkeletonPulse pulse={pulse} style={styles.hintPlaceholder} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
leading: {
|
||||
marginRight: theme.spacing[3],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
control: {
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
// The button under the row's content; it fills the row, padding included.
|
||||
pressTarget: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
passThrough: {
|
||||
pointerEvents: "none",
|
||||
},
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexShrink: 1,
|
||||
},
|
||||
hintLine: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
// The line carries the hint's top margin, so the mark and the words share one baseline box.
|
||||
hintText: {
|
||||
marginTop: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
hintError: {
|
||||
color: theme.colors.statusDanger,
|
||||
},
|
||||
errorDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
hintPlaceholder: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: theme.spacing[0.5],
|
||||
bottom: theme.spacing[0.5],
|
||||
width: theme.spacing[32],
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
labelRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
summary: {
|
||||
flexShrink: 1,
|
||||
|
||||
@@ -462,7 +462,9 @@ describe("sidebar group visibility", () => {
|
||||
|
||||
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
|
||||
const items = entries(Array(count).fill("done"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
|
||||
);
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.current.canToggle).toBe(count > 7);
|
||||
});
|
||||
@@ -472,7 +474,7 @@ describe("sidebar group visibility", () => {
|
||||
(status) => {
|
||||
const items = entries([...Array(11).fill("done"), status]);
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, policy }),
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
|
||||
);
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-1",
|
||||
@@ -497,6 +499,7 @@ describe("sidebar group visibility", () => {
|
||||
useLimitedSidebarGroup({
|
||||
items,
|
||||
selection: null,
|
||||
expanded: false,
|
||||
policy: createWorkspaceVisibilityPolicy(entriesByKey),
|
||||
}),
|
||||
);
|
||||
@@ -518,7 +521,9 @@ describe("sidebar group visibility", () => {
|
||||
"needs_input",
|
||||
"failed",
|
||||
]);
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
|
||||
);
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
});
|
||||
@@ -532,9 +537,10 @@ describe("sidebar group visibility", () => {
|
||||
"done",
|
||||
"attention",
|
||||
]);
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
|
||||
{ initialProps: { rows: items } },
|
||||
const { result, rerender } = renderHook(
|
||||
({ rows, expanded }) =>
|
||||
useLimitedSidebarGroup({ items: rows, selection: null, expanded, policy }),
|
||||
{ initialProps: { rows: items, expanded: false } },
|
||||
);
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-1",
|
||||
@@ -548,16 +554,15 @@ describe("sidebar group visibility", () => {
|
||||
"row-10",
|
||||
"row-12",
|
||||
]);
|
||||
act(() => result.current.toggleExpanded());
|
||||
rerender({ rows: items, expanded: true });
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) });
|
||||
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })), expanded: true });
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
expect(result.current.expanded).toBe(true);
|
||||
rerender({ rows: items.slice(0, 3) });
|
||||
rerender({ rows: items.slice(0, 3), expanded: true });
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
rerender({ rows: items.toReversed() });
|
||||
rerender({ rows: items.toReversed(), expanded: true });
|
||||
expect(result.current.visibleItems).toEqual(items.toReversed());
|
||||
act(() => result.current.toggleExpanded());
|
||||
rerender({ rows: items.toReversed(), expanded: false });
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-12",
|
||||
"row-11",
|
||||
@@ -568,15 +573,25 @@ describe("sidebar group visibility", () => {
|
||||
"row-6",
|
||||
]);
|
||||
const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" };
|
||||
rerender({ rows: [newest, ...items] });
|
||||
rerender({ rows: [newest, ...items], expanded: false });
|
||||
expect(result.current.visibleItems[0]).toBe(newest);
|
||||
expect(result.current.visibleItems).toHaveLength(10);
|
||||
expect(result.current.expanded).toBe(false);
|
||||
act(() => result.current.toggleExpanded());
|
||||
unmount();
|
||||
const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
expect(remounted.result.current.expanded).toBe(false);
|
||||
expect(remounted.result.current.visibleItems).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("shows the reveal its owner passes in on every mount", () => {
|
||||
// A native drop remounts every project block it moves, so the group holds no reveal of its
|
||||
// own. Only the native list remounts rows; the browser list never does, so no test here can
|
||||
// drive that remount.
|
||||
const items = entries([...Array(7).fill("done"), "running", "done", "failed", "done"]);
|
||||
const first = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: true, policy }),
|
||||
);
|
||||
expect(first.result.current.visibleItems).toEqual(items);
|
||||
first.unmount();
|
||||
const remounted = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: true, policy }),
|
||||
);
|
||||
expect(remounted.result.current.visibleItems).toEqual(items);
|
||||
});
|
||||
|
||||
it("follows effective pending creation, recovery and agent activity without changing Created order", () => {
|
||||
@@ -602,6 +617,7 @@ describe("sidebar group visibility", () => {
|
||||
useLimitedSidebarGroup({
|
||||
items: [...items.slice(0, 11), last],
|
||||
selection: null,
|
||||
expanded: false,
|
||||
policy,
|
||||
}),
|
||||
{ initialProps: { last: items[11] } },
|
||||
@@ -641,12 +657,15 @@ describe("sidebar group visibility", () => {
|
||||
|
||||
it("preserves the twenty-row pinned policy regardless of status", () => {
|
||||
const items = entries(Array(21).fill("running"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null }));
|
||||
const { result, rerender } = renderHook(
|
||||
({ expanded }) => useLimitedSidebarGroup({ items, selection: null, expanded }),
|
||||
{ initialProps: { expanded: false } },
|
||||
);
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
act(() => result.current.toggleExpanded());
|
||||
rerender({ expanded: true });
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
act(() => result.current.toggleExpanded());
|
||||
rerender({ expanded: false });
|
||||
expect(result.current.visibleItems).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,6 @@ import {
|
||||
import { ProjectLeadingVisual } from "@/components/sidebar/project-leading-visual";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
@@ -368,12 +367,10 @@ function projectKebabStyle({
|
||||
|
||||
function getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
}: {
|
||||
isDragging: boolean;
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
@@ -382,7 +379,6 @@ function getProjectWorkspaceRowStyle({
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -820,7 +816,6 @@ function NewWorkspaceGhostRow({
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.newWorkspaceGhostRow,
|
||||
hovered && !pressed && styles.newWorkspaceGhostRowHovered,
|
||||
pressed && styles.newWorkspaceGhostRowPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
@@ -833,6 +828,7 @@ function NewWorkspaceGhostRow({
|
||||
})}
|
||||
onPress={handlePress}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.newWorkspaceGhostRowPressed}
|
||||
testID={`sidebar-project-new-workspace-row-${project.viewKey}`}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
@@ -883,7 +879,7 @@ function ProjectHeaderRow({
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||
const localDaemonServerId = useLocalDaemonServerId();
|
||||
@@ -930,25 +926,13 @@ function ProjectHeaderRow({
|
||||
setContextMenuOpen(open);
|
||||
if (open) setIsHovered(false);
|
||||
}, []);
|
||||
const handleProjectPressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
interaction.handlePressIn(event);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleProjectPressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
interaction.handlePressOut();
|
||||
}, [interaction]);
|
||||
|
||||
const projectRowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
const projectRowStyle = useMemo(
|
||||
() => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.projectRowHovered,
|
||||
pressed && styles.projectRowPressed,
|
||||
],
|
||||
[isDragging, selected, isHovered],
|
||||
);
|
||||
@@ -961,7 +945,7 @@ function ProjectHeaderRow({
|
||||
iconDataUri={iconDataUri}
|
||||
statusBucket={statusBucket}
|
||||
projectViewKey={project.viewKey}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered })}
|
||||
chevron={chevron}
|
||||
showChevron={isHovered && chevron !== null}
|
||||
isArchiving={isArchiving}
|
||||
@@ -1007,9 +991,10 @@ function ProjectHeaderRow({
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
highlightStyle={styles.projectRowPressed}
|
||||
onPressIn={handleProjectPressIn}
|
||||
onHighlightChange={setIsHighlighted}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={handleProjectPressOut}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
>
|
||||
@@ -1033,9 +1018,10 @@ function ProjectHeaderRow({
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
highlightStyle={styles.projectRowPressed}
|
||||
onPressIn={handleProjectPressIn}
|
||||
onHighlightChange={setIsHighlighted}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={handleProjectPressOut}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
>
|
||||
@@ -1091,7 +1077,7 @@ function WorkspaceRowInner({
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
@@ -1111,17 +1097,6 @@ function WorkspaceRowInner({
|
||||
}
|
||||
onPress();
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
const handleWorkspacePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
interaction.handlePressIn(event);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleWorkspacePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
interaction.handlePressOut();
|
||||
}, [interaction]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const isEditingTitle = titleEditor !== null;
|
||||
@@ -1135,13 +1110,8 @@ function WorkspaceRowInner({
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
const workspaceRowStyle = getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
const workspaceRowStyle = getProjectWorkspaceRowStyle({ isDragging, selected, isHovered });
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
@@ -1176,10 +1146,11 @@ function WorkspaceRowInner({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
|
||||
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
|
||||
onHighlightChange={setIsHighlighted}
|
||||
onPressIn={isEditingTitle ? undefined : interaction.handlePressIn}
|
||||
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
|
||||
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
|
||||
onPressOut={isEditingTitle ? undefined : interaction.handlePressOut}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -1280,8 +1251,9 @@ function WorkspaceRowWithMenu({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
diffStat: workspace.diffStat,
|
||||
onArchiveStarted: redirectAfterArchive,
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
});
|
||||
@@ -1557,6 +1529,8 @@ function ProjectBlock({
|
||||
showShortcutBadges,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
onToggleCollapsed,
|
||||
workspacesExpanded,
|
||||
onToggleWorkspacesExpanded,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
onWorkspaceReorder,
|
||||
@@ -1583,6 +1557,9 @@ function ProjectBlock({
|
||||
showShortcutBadges: boolean;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onToggleCollapsed: (projectViewKey: string) => void;
|
||||
/** "Show more" on this project's workspaces, owned by the list so a drop's remount keeps it. */
|
||||
workspacesExpanded: boolean;
|
||||
onToggleWorkspacesExpanded: (projectViewKey: string) => void;
|
||||
onWorkspacePress?: () => void;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
@@ -1607,12 +1584,11 @@ function ProjectBlock({
|
||||
const {
|
||||
leadingItem: leadingWorkspace,
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup({
|
||||
items: project.workspaces,
|
||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
||||
expanded: workspacesExpanded,
|
||||
policy: visibilityPolicy,
|
||||
});
|
||||
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||
@@ -1770,6 +1746,10 @@ function ProjectBlock({
|
||||
onToggleCollapsed(project.viewKey);
|
||||
}, [onToggleCollapsed, project.viewKey]);
|
||||
|
||||
const handleToggleWorkspacesExpanded = useCallback(() => {
|
||||
onToggleWorkspacesExpanded(project.viewKey);
|
||||
}, [onToggleWorkspacesExpanded, project.viewKey]);
|
||||
|
||||
let projectChildren = null;
|
||||
if (!collapsed) {
|
||||
if (project.workspaces.length > 0) {
|
||||
@@ -1806,7 +1786,7 @@ function ProjectBlock({
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
onPress={toggleWorkspacesExpanded}
|
||||
onPress={handleToggleWorkspacesExpanded}
|
||||
testID={`sidebar-project-show-more-${project.viewKey}`}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1876,6 +1856,8 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.workspacesExpanded === next.workspacesExpanded &&
|
||||
previous.onToggleWorkspacesExpanded === next.onToggleWorkspacesExpanded &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
|
||||
@@ -2204,15 +2186,29 @@ function ProjectModeList({
|
||||
const selectionEnabled = isWorkspaceRoute;
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
||||
const [pinnedChatsExpanded, setPinnedChatsExpanded] = useState(false);
|
||||
const togglePinnedChatsExpanded = useCallback(
|
||||
() => setPinnedChatsExpanded((current) => !current),
|
||||
[],
|
||||
);
|
||||
const [expandedProjectKeys, setExpandedProjectKeys] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const toggleProjectWorkspacesExpanded = useCallback((projectViewKey: string) => {
|
||||
setExpandedProjectKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(projectViewKey)) next.add(projectViewKey);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const {
|
||||
leadingItem: leadingPinnedChat,
|
||||
visibleItems: visiblePinnedChats,
|
||||
expanded: pinnedChatsExpanded,
|
||||
canToggle: canTogglePinnedChats,
|
||||
toggleExpanded: togglePinnedChatsExpanded,
|
||||
} = useLimitedSidebarGroup({
|
||||
items: pinnedChats,
|
||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
||||
expanded: pinnedChatsExpanded,
|
||||
});
|
||||
const nativeScrollGestureProps = useMemo(
|
||||
() =>
|
||||
@@ -2368,6 +2364,8 @@ function ProjectModeList({
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onToggleCollapsed={onToggleProjectCollapsed}
|
||||
workspacesExpanded={expandedProjectKeys.has(item.viewKey)}
|
||||
onToggleWorkspacesExpanded={toggleProjectWorkspacesExpanded}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
manualSort={manualSort}
|
||||
parentGestureRef={parentGestureRef}
|
||||
@@ -2389,6 +2387,8 @@ function ProjectModeList({
|
||||
},
|
||||
[
|
||||
collapsedProjectKeys,
|
||||
expandedProjectKeys,
|
||||
toggleProjectWorkspacesExpanded,
|
||||
activeWorkspaceSelection,
|
||||
handleWorktreeCreated,
|
||||
manualSort,
|
||||
@@ -2592,9 +2592,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
|
||||
// Schedules icon across the divider; their layout boxes have different insets.
|
||||
paddingTop: 2,
|
||||
// Keep the last row scrollable above the sidebar toast, including a wrapped Undo row.
|
||||
// This space stays present across toast changes so the scroll range never jumps.
|
||||
paddingBottom: theme.spacing[24],
|
||||
// The sidebar toast floats over the list's end. Keep this padding the same whether or not
|
||||
// a toast shows: changing it moves the rows when the toast leaves.
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
projectListContainer: {
|
||||
width: "100%",
|
||||
|
||||
@@ -39,7 +39,6 @@ export function SidebarGroupToggleRow({
|
||||
styles.row,
|
||||
indented && sidebarWorkspaceRowStyles.rowIndented,
|
||||
hovered && !pressed && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
],
|
||||
[indented],
|
||||
);
|
||||
@@ -50,6 +49,7 @@ export function SidebarGroupToggleRow({
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.rowPressed}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
|
||||
@@ -12,16 +12,17 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
*/
|
||||
export function getSidebarRowBackdrop({
|
||||
isDragging = false,
|
||||
isPressed = false,
|
||||
isHighlighted = false,
|
||||
selected = false,
|
||||
isHovered = false,
|
||||
}: {
|
||||
isDragging?: boolean;
|
||||
isPressed?: boolean;
|
||||
/** The press highlight is painting the row's pressed fill. */
|
||||
isHighlighted?: boolean;
|
||||
selected?: boolean;
|
||||
isHovered?: boolean;
|
||||
}): SidebarSurfaceBackdrop {
|
||||
if (isDragging || isPressed) return "surface2";
|
||||
if (isDragging || isHighlighted) return "surface2";
|
||||
if (selected) return "surfaceSidebarSelected";
|
||||
if (isHovered) return "surfaceSidebarHover";
|
||||
return "surfaceSidebar";
|
||||
|
||||
@@ -9,13 +9,7 @@ import {
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import type { GestureType } from "react-native-gesture-handler";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -44,7 +38,6 @@ import { useSidebarWorkspaceRename } from "@/workspace-rename";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
@@ -155,13 +148,20 @@ export function SidebarStatusWorkspaceList({
|
||||
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
const [pinnedWorkspacesExpanded, setPinnedWorkspacesExpanded] = useState(false);
|
||||
const togglePinnedWorkspacesExpanded = useCallback(
|
||||
() => setPinnedWorkspacesExpanded((current) => !current),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
leadingItem: leadingPinnedWorkspace,
|
||||
visibleItems: visiblePinnedWorkspaces,
|
||||
expanded: pinnedWorkspacesExpanded,
|
||||
canToggle: canTogglePinnedWorkspaces,
|
||||
toggleExpanded: togglePinnedWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
|
||||
} = useLimitedSidebarGroup({
|
||||
items: pinnedWorkspaces,
|
||||
selection: activeWorkspaceSelection,
|
||||
expanded: pinnedWorkspacesExpanded,
|
||||
});
|
||||
|
||||
const statusShortcutIndex = showShortcutBadges
|
||||
? shortcutIndexByWorkspaceKey
|
||||
@@ -366,15 +366,19 @@ function StatusGroupRows({
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const [workspacesExpanded, setWorkspacesExpanded] = useState(false);
|
||||
const toggleWorkspacesExpanded = useCallback(
|
||||
() => setWorkspacesExpanded((current) => !current),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
leadingItem: leadingWorkspace,
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup({
|
||||
items: group.rows,
|
||||
selection: activeWorkspaceSelection,
|
||||
expanded: workspacesExpanded,
|
||||
policy: STATUS_GROUP_VISIBILITY,
|
||||
});
|
||||
|
||||
@@ -460,12 +464,8 @@ function StatusGroupHeader({
|
||||
}, [group.key, toggleWorkspaceGroupCollapsed]);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
const rowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
styles.statusGroupRow,
|
||||
isHovered && styles.statusGroupRowHovered,
|
||||
pressed && styles.statusGroupRowPressed,
|
||||
],
|
||||
const rowStyle = useMemo(
|
||||
() => [styles.statusGroupRow, isHovered && styles.statusGroupRowHovered],
|
||||
[isHovered],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||
@@ -477,6 +477,7 @@ function StatusGroupHeader({
|
||||
accessibilityLabel={t("sidebar.statusGroupAccessibility", { label: group.label })}
|
||||
accessibilityState={accessibilityState}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.statusGroupRowPressed}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-status-group-${group.key}`}
|
||||
>
|
||||
@@ -654,8 +655,9 @@ function StatusWorkspaceRowWithMenu({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
diffStat: workspace.diffStat,
|
||||
onArchiveStarted: redirectAfterArchive,
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
});
|
||||
@@ -826,7 +828,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const {
|
||||
role: _dragRole,
|
||||
@@ -851,17 +853,6 @@ function StatusWorkspaceRowInnerContent({
|
||||
}
|
||||
onPress();
|
||||
}, [didLongPressRef, onPress]);
|
||||
const handlePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
startDragPress?.(event);
|
||||
},
|
||||
[startDragPress],
|
||||
);
|
||||
const handlePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
endDragPress?.();
|
||||
}, [endDragPress]);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame
|
||||
@@ -887,13 +878,12 @@ function StatusWorkspaceRowInnerContent({
|
||||
showShortcut,
|
||||
});
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
isDragging,
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
@@ -928,10 +918,11 @@ function StatusWorkspaceRowInnerContent({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={isEditingTitle ? undefined : handlePressIn}
|
||||
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
|
||||
onHighlightChange={setIsHighlighted}
|
||||
onPressIn={isEditingTitle ? undefined : startDragPress}
|
||||
onTouchMove={isEditingTitle ? undefined : moveDragPress}
|
||||
onPressOut={isEditingTitle ? undefined : handlePressOut}
|
||||
onPressOut={isEditingTitle ? undefined : endDragPress}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -1068,13 +1059,11 @@ function StatusWorkspaceActionSlot({
|
||||
}
|
||||
|
||||
function getStatusWorkspaceRowStyle({
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
isDragging,
|
||||
}: {
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
inStatusGroup: boolean;
|
||||
@@ -1086,7 +1075,6 @@ function getStatusWorkspaceRowStyle({
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1101,8 +1089,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
|
||||
paddingTop: 2,
|
||||
// Match project grouping: permanent room to scroll the final row above sidebar toasts.
|
||||
paddingBottom: theme.spacing[24],
|
||||
// The sidebar toast floats over the list's end. Keep this padding the same whether or not
|
||||
// a toast shows: changing it moves the rows when the toast leaves.
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
pinnedSection: {
|
||||
marginBottom: theme.spacing[1],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
selectSidebarGroupVisibility,
|
||||
type SidebarGroupItem,
|
||||
@@ -12,23 +12,22 @@ const PINNED_POLICY = { kind: "pinned" } as const;
|
||||
interface LimitedSidebarGroupInput<T> {
|
||||
items: readonly T[];
|
||||
selection: ActiveWorkspaceSelection | null;
|
||||
/**
|
||||
* Whether the user revealed the whole group. The caller owns it: a native drop remounts the
|
||||
* rows it moved, and a reveal held inside a row would reset with them.
|
||||
*/
|
||||
expanded: boolean;
|
||||
policy?: SidebarGroupVisibilityPolicy<T>;
|
||||
}
|
||||
|
||||
export function useLimitedSidebarGroup<T extends SidebarGroupItem>({
|
||||
items,
|
||||
selection,
|
||||
expanded,
|
||||
policy = PINNED_POLICY,
|
||||
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
|
||||
expanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
} {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibility = useMemo(
|
||||
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> {
|
||||
return useMemo(
|
||||
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
|
||||
[expanded, items, policy, selection],
|
||||
);
|
||||
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
||||
|
||||
return { ...visibility, expanded, toggleExpanded };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
import React, { act, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { View } from "react-native";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||
import type { AutocompleteOption } from "./autocomplete";
|
||||
import { AutocompletePopover } from "./autocomplete-popover";
|
||||
import { FloatingPanelPortalHost } from "./floating-panel-portal";
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
/**
|
||||
* The popover must paint its first frame at its place. On native, Reanimated computes a style's
|
||||
* first value when its hook first runs and sends later values after the mounting commit, which
|
||||
* native paints: a bound computed while the popover was closed hung the popover over the composer
|
||||
* for a frame on every open. The frame now mounts only with a measured anchor. This suite runs the
|
||||
* web frame; the native frame was checked frame by frame on Android.
|
||||
*/
|
||||
|
||||
const SAFE_AREA = {
|
||||
frame: { x: 0, y: 0, width: 390, height: 844 },
|
||||
insets: { top: 0, left: 0, right: 0, bottom: 0 },
|
||||
};
|
||||
// The composer rests on the dock's bottom edge and grows upward, as in the app.
|
||||
const COMPOSER_BOTTOM = 124;
|
||||
const OFFSET_FROM_ANCHOR = 12;
|
||||
const OPTIONS: AutocompleteOption[] = ["help", "hello", "history"].map((name) => ({
|
||||
id: name,
|
||||
label: `/${name}`,
|
||||
icon: "command",
|
||||
}));
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
const SCREEN_STYLE = { width: 390, height: 844 };
|
||||
const styleCache = new Map<string, object>();
|
||||
function cachedStyle<T extends object>(key: string, create: () => T): T {
|
||||
const cached = styleCache.get(key);
|
||||
if (cached) return cached as T;
|
||||
const style = create();
|
||||
styleCache.set(key, style);
|
||||
return style;
|
||||
}
|
||||
function composerStyle(width: number): object {
|
||||
return cachedStyle(`composer:${width}`, () => ({
|
||||
position: "absolute" as const,
|
||||
bottom: COMPOSER_BOTTOM,
|
||||
left: 16,
|
||||
width,
|
||||
}));
|
||||
}
|
||||
function inputStyle(height: number): object {
|
||||
return cachedStyle(`input:${height}`, () => ({ height }));
|
||||
}
|
||||
function ignoreSelection(): void {}
|
||||
|
||||
function Providers({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<SafeAreaProvider initialMetrics={SAFE_AREA}>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>{children}</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScreenProps {
|
||||
visible: boolean;
|
||||
panelActive?: boolean;
|
||||
inputHeight?: number;
|
||||
composerWidth?: number;
|
||||
options?: AutocompleteOption[];
|
||||
/** Counted up from the best match, which is the last option. */
|
||||
selectedFromBest?: number;
|
||||
}
|
||||
|
||||
function Screen({
|
||||
visible,
|
||||
panelActive = true,
|
||||
inputHeight = 120,
|
||||
composerWidth = 358,
|
||||
options = OPTIONS,
|
||||
selectedFromBest = 0,
|
||||
}: ScreenProps): ReactNode {
|
||||
return (
|
||||
<Providers>
|
||||
<RetainedPanel active={panelActive}>
|
||||
<View style={SCREEN_STYLE}>
|
||||
<View style={composerStyle(composerWidth)}>
|
||||
<AutocompletePopover
|
||||
visible={visible}
|
||||
options={options}
|
||||
selectedIndex={options.length - 1 - selectedFromBest}
|
||||
onSelect={ignoreSelection}
|
||||
>
|
||||
<View testID="composer-input" style={inputStyle(inputHeight)} />
|
||||
</AutocompletePopover>
|
||||
</View>
|
||||
<FloatingPanelPortalHost />
|
||||
</View>
|
||||
</RetainedPanel>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
|
||||
function render(node: ReactNode): void {
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
}
|
||||
act(() => root?.render(node));
|
||||
}
|
||||
|
||||
/** Commits a change to the screen synchronously and lets the browser paint it. */
|
||||
async function changeAndPaint(node: ReactNode): Promise<void> {
|
||||
flushSync(() => root?.render(node));
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.addEventListener("message", () => resolve(), { once: true });
|
||||
channel.port1.start();
|
||||
channel.port2.postMessage(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rectOf(testID: string): DOMRect | null {
|
||||
return document.querySelector(`[data-testid="${testID}"]`)?.getBoundingClientRect() ?? null;
|
||||
}
|
||||
|
||||
/** Where the popover sits against the composer, or null when it is not painted. */
|
||||
function placementAgainstComposer(): { gap: number; x: number; width: number } | null {
|
||||
const popover = rectOf("composer-autocomplete-popover");
|
||||
const composer = rectOf("composer-input");
|
||||
if (!popover || !composer) return null;
|
||||
return {
|
||||
gap: Math.round(composer.top - popover.bottom),
|
||||
x: Math.round(popover.x - composer.x),
|
||||
width: Math.round(popover.width - composer.width),
|
||||
};
|
||||
}
|
||||
|
||||
async function openSettled(props: Omit<ScreenProps, "visible"> = {}): Promise<void> {
|
||||
render(<Screen visible={false} {...props} />);
|
||||
render(<Screen visible {...props} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
}
|
||||
|
||||
/** Every max-height the popover's frame has held, starting with the one it mounted with. */
|
||||
function recordPopoverMaxHeights(): { values: string[]; stop: () => void } {
|
||||
const values: string[] = [];
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
if (record.type === "childList") {
|
||||
for (const node of record.addedNodes) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
const popover = node.matches('[data-testid="composer-autocomplete-popover"]')
|
||||
? node
|
||||
: node.querySelector<HTMLElement>('[data-testid="composer-autocomplete-popover"]');
|
||||
// The attribute records below carry what the style was before each change; this
|
||||
// carries the style at insertion when nothing changed it afterwards.
|
||||
if (popover && values.length === 0) values.push(popover.style.maxHeight);
|
||||
}
|
||||
}
|
||||
if (
|
||||
record.type === "attributes" &&
|
||||
record.target instanceof HTMLElement &&
|
||||
record.target.dataset.testid === "composer-autocomplete-popover"
|
||||
) {
|
||||
const before = /max-height:\s*([^;]+)/.exec(record.oldValue ?? "")?.[1]?.trim() ?? "";
|
||||
if (values.length <= 1) values.splice(0, values.length, before);
|
||||
values.push(record.target.style.maxHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
attributeOldValue: true,
|
||||
});
|
||||
return { values, stop: () => observer.disconnect() };
|
||||
}
|
||||
|
||||
async function waitForPopover(): Promise<HTMLElement> {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const popover = document.querySelector<HTMLElement>(
|
||||
'[data-testid="composer-autocomplete-popover"]',
|
||||
);
|
||||
if (popover) return popover;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error("The popover did not open");
|
||||
}
|
||||
|
||||
it("opens with its height bound instead of the bound it had while closed", async () => {
|
||||
// The composer mounts first and opens the popover later, as typing `/` does.
|
||||
render(<Screen visible={false} />);
|
||||
const recording = recordPopoverMaxHeights();
|
||||
render(<Screen visible />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
recording.stop();
|
||||
|
||||
const settled = popover.style.maxHeight;
|
||||
expect(Number.parseFloat(settled)).toBeGreaterThan(0);
|
||||
expect(recording.values[0]).toBe(settled);
|
||||
});
|
||||
|
||||
/**
|
||||
* The popover lives in a portal and copies the composer's rect. A copy taken after the composer's
|
||||
* layout paints the composer at its new size against the popover's old place: the popover overlapped
|
||||
* a composer that grew a line, floated above one that shrank, and kept the old width after a resize.
|
||||
*/
|
||||
it("moves with the composer in the frame the composer grows", async () => {
|
||||
await openSettled();
|
||||
await changeAndPaint(<Screen visible inputHeight={160} />);
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
});
|
||||
|
||||
it("follows the composer down when its text clears before the popover closes", async () => {
|
||||
// The input relayouts on the cleared text before the autocomplete hides the popover.
|
||||
await openSettled({ inputHeight: 160 });
|
||||
await changeAndPaint(<Screen visible inputHeight={120} />);
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
});
|
||||
|
||||
it("matches the composer's width in the frame the composer resizes", async () => {
|
||||
await openSettled();
|
||||
await changeAndPaint(<Screen visible composerWidth={240} />);
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
});
|
||||
|
||||
/**
|
||||
* A retained workspace stays mounted while another route shows. A popover left open there tracked a
|
||||
* composer it could not measure, and on native stopped following the composer after the user came
|
||||
* back.
|
||||
*/
|
||||
it("closes while its retained panel is hidden and reopens in place when it shows again", async () => {
|
||||
await openSettled();
|
||||
render(<Screen visible panelActive={false} />);
|
||||
expect(rectOf("composer-autocomplete-popover")).toBeNull();
|
||||
render(<Screen visible />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
});
|
||||
|
||||
/** As many commands as a workspace with skills installed lists; the best match is last. */
|
||||
function commandOptions(prefix: string, count: number): AutocompleteOption[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `${prefix}-${count - index}`,
|
||||
label: `/${prefix}-${count - index}`,
|
||||
icon: "skill" as const,
|
||||
description: `Description of ${prefix} ${count - index}`,
|
||||
tag: "Skill",
|
||||
}));
|
||||
}
|
||||
|
||||
const LIST_PADDING_AND_BORDER = 5;
|
||||
const ROW_HEIGHT_AT_LEAST = 36;
|
||||
|
||||
/** How far the best match's row sits above the popover's bottom edge, or null when not painted. */
|
||||
function bestMatchAgainstPopoverBottom(label: string): number | null {
|
||||
const popover = rectOf("composer-autocomplete-popover");
|
||||
const row = document.querySelector(`[aria-label^="${label},"]`)?.getBoundingClientRect();
|
||||
if (!popover) return null;
|
||||
if (!row) return Number.NaN;
|
||||
return Math.round(popover.bottom - row.bottom);
|
||||
}
|
||||
|
||||
/** Samples a reading after every paint until stopped. */
|
||||
function recordPaintedFrames<T>(read: () => T): { frames: T[]; stop: () => void } {
|
||||
const frames: T[] = [];
|
||||
let running = true;
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.addEventListener("message", () => {
|
||||
if (running) frames.push(read());
|
||||
});
|
||||
channel.port1.start();
|
||||
const tick = (): void => {
|
||||
if (!running) return;
|
||||
channel.port2.postMessage(null);
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
return {
|
||||
frames,
|
||||
stop: () => {
|
||||
running = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The list is longer than the popover and reads upward from its best match, which sits against the
|
||||
* bottom edge. Scrolling there after the list mounted painted the list at its old offset first: a
|
||||
* few pixels short on open, a different stretch of the list when the results changed.
|
||||
*/
|
||||
it("paints a long list with its best match against the bottom from the first frame", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
recording.stop();
|
||||
|
||||
const painted = recording.frames.filter((frame) => frame !== null);
|
||||
expect(painted.length).toBeGreaterThan(0);
|
||||
expect([...new Set(painted)]).toEqual([LIST_PADDING_AND_BORDER]);
|
||||
});
|
||||
|
||||
it("keeps the best match against the bottom in the frame the results change", async () => {
|
||||
const all = commandOptions("skill", 100);
|
||||
const fewer = all.filter((_, index) => index % 3 === 0);
|
||||
render(<Screen visible={false} options={all} />);
|
||||
render(<Screen visible options={all} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Narrowing keeps `/skill-1` the best match; widening brings the rest back above it.
|
||||
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
|
||||
await changeAndPaint(<Screen visible options={fewer} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await changeAndPaint(<Screen visible options={all} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
recording.stop();
|
||||
|
||||
expect([...new Set(recording.frames)]).toEqual([LIST_PADDING_AND_BORDER]);
|
||||
});
|
||||
|
||||
function mountedRows(prefix = "skill"): Element[] {
|
||||
return [...document.querySelectorAll(`[aria-label^="/${prefix}-"]`)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every result used to mount before the popover's first frame, so a workspace with a hundred
|
||||
* commands waited on a hundred rows to see six.
|
||||
*/
|
||||
it("mounts the rows nearest the composer first, however many results there are", async () => {
|
||||
const options = commandOptions("skill", 500);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
const recording = recordPaintedFrames(() =>
|
||||
rectOf("composer-autocomplete-popover") ? mountedRows().length : null,
|
||||
);
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
recording.stop();
|
||||
|
||||
const firstFrame = recording.frames.find((frame) => frame !== null);
|
||||
expect(firstFrame).toBeGreaterThan(0);
|
||||
expect(firstFrame).toBeLessThan(20);
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
/**
|
||||
* A list that had mounted its whole window kept that window for the next results, so narrowing a
|
||||
* long list to another long one waited on ten times the rows the popover shows.
|
||||
*/
|
||||
it("mounts the rows nearest the composer first when the results change", async () => {
|
||||
const commands = commandOptions("skill", 500);
|
||||
const matches = commandOptions("plugin", 300);
|
||||
render(<Screen visible={false} options={commands} />);
|
||||
render(<Screen visible options={commands} />);
|
||||
await waitForPopover();
|
||||
await expect.poll(() => mountedRows().length, { timeout: 3000 }).toBeGreaterThan(40);
|
||||
|
||||
const recording = recordPaintedFrames(() => mountedRows("plugin").length);
|
||||
await changeAndPaint(<Screen visible options={matches} />);
|
||||
recording.stop();
|
||||
|
||||
const firstFrame = recording.frames.find((count) => count > 0);
|
||||
expect(firstFrame).toBeLessThan(20);
|
||||
expect(bestMatchAgainstPopoverBottom("/plugin-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
function scrollerOf(popover: HTMLElement): HTMLElement {
|
||||
const scroller = [...popover.querySelectorAll<HTMLElement>("*")].find(
|
||||
(element) =>
|
||||
element.scrollHeight > element.clientHeight + 1 &&
|
||||
["auto", "scroll"].includes(getComputedStyle(element).overflowY),
|
||||
);
|
||||
if (!scroller) throw new Error("The list does not scroll");
|
||||
return scroller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native paints a scroll command a frame or two after the rows it was meant for. A list that rests
|
||||
* against the composer only because script scrolled it there shows the rows somewhere else first,
|
||||
* so the composer's edge is where the list's scroll starts.
|
||||
*/
|
||||
it("rests against the composer where its scroll starts", async () => {
|
||||
const all = commandOptions("skill", 100);
|
||||
const fewer = all.filter((_, index) => index % 3 === 0);
|
||||
render(<Screen visible={false} options={all} />);
|
||||
render(<Screen visible options={all} />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(scrollerOf(popover).scrollTop).toBe(0);
|
||||
|
||||
await changeAndPaint(<Screen visible options={fewer} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(scrollerOf(popover).scrollTop).toBe(0);
|
||||
|
||||
await changeAndPaint(<Screen visible options={all} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(scrollerOf(popover).scrollTop).toBe(0);
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
it("returns to the best match in the frame results arrive while the list is scrolled away", async () => {
|
||||
const all = commandOptions("skill", 100);
|
||||
const fewer = all.filter((_, index) => index % 3 === 0);
|
||||
render(<Screen visible={false} options={all} />);
|
||||
render(<Screen visible options={all} />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
scrollerOf(popover).scrollTop = 400;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).not.toBe(LIST_PADDING_AND_BORDER);
|
||||
|
||||
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
|
||||
await changeAndPaint(<Screen visible options={fewer} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
recording.stop();
|
||||
|
||||
expect([...new Set(recording.frames)]).toEqual([LIST_PADDING_AND_BORDER]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The composer hands over a new list on every keystroke and every refetch, often holding the results
|
||||
* it already showed. Starting over for those returned a list the user had scrolled to its rest.
|
||||
*/
|
||||
it("keeps its place when the same results arrive again", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
scrollerOf(popover).scrollTop = 400;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const scrolledAway = bestMatchAgainstPopoverBottom("/skill-1");
|
||||
expect(scrolledAway).not.toBe(LIST_PADDING_AND_BORDER);
|
||||
|
||||
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
|
||||
await changeAndPaint(<Screen visible options={commandOptions("skill", 100)} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
recording.stop();
|
||||
|
||||
expect([...new Set(recording.frames)]).toEqual([scrolledAway]);
|
||||
});
|
||||
|
||||
/** Whether the row is wholly inside the popover's box. */
|
||||
function isRowInView(label: string): boolean {
|
||||
const popover = rectOf("composer-autocomplete-popover");
|
||||
const row = document.querySelector(`[aria-label^="${label},"]`)?.getBoundingClientRect();
|
||||
if (!popover || !row) return false;
|
||||
return row.top >= popover.top - 0.5 && row.bottom <= popover.bottom + 0.5;
|
||||
}
|
||||
|
||||
it("reveals the row the arrow keys select, including across the wrap to the far end", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
for (const selectedFromBest of [1, 5, 6, 7, 12, 6, 0, 99, 98, 93, 0]) {
|
||||
render(<Screen visible options={options} selectedFromBest={selectedFromBest} />);
|
||||
// Rows beyond the mounted ones come into view as they mount.
|
||||
await expect
|
||||
.poll(() => [selectedFromBest, isRowInView(`/skill-${selectedFromBest + 1}`)], {
|
||||
timeout: 3000,
|
||||
})
|
||||
.toEqual([selectedFromBest, true]);
|
||||
}
|
||||
await expect.poll(() => bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
/** The composer grew until the space above it holds fewer rows than the popover was showing. */
|
||||
it("keeps the selected row in view when the popover gets shorter", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
// Beyond the rows the popover opened with, so it came into view at the popover's top edge.
|
||||
render(<Screen visible options={options} selectedFromBest={7} />);
|
||||
await expect.poll(() => isRowInView("/skill-8"), { timeout: 3000 }).toBe(true);
|
||||
const height = rectOf("composer-autocomplete-popover")!.height;
|
||||
|
||||
render(<Screen visible options={options} selectedFromBest={7} inputHeight={560} />);
|
||||
await expect
|
||||
.poll(() => rectOf("composer-autocomplete-popover")!.height, { timeout: 3000 })
|
||||
.toBeLessThan(height - ROW_HEIGHT_AT_LEAST);
|
||||
await expect.poll(() => isRowInView("/skill-8"), { timeout: 3000 }).toBe(true);
|
||||
});
|
||||
|
||||
/** The label of the row at the top of the popover's box. */
|
||||
function topRowInView(): string | null {
|
||||
const popover = rectOf("composer-autocomplete-popover");
|
||||
if (!popover) return null;
|
||||
const rows = mountedRows()
|
||||
.map((row) => ({ rect: row.getBoundingClientRect(), label: row.getAttribute("aria-label") }))
|
||||
.filter(({ rect }) => rect.top >= popover.top - 0.5 && rect.bottom <= popover.bottom + 0.5)
|
||||
.toSorted((a, b) => a.rect.top - b.rect.top);
|
||||
return rows[0]?.label?.split(",")[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The far end of a long list is not mounted while the list rests against the composer. Scrolling to
|
||||
* it as rows mounted painted the list at several places on the way.
|
||||
*/
|
||||
it("jumps between the two ends in one frame when the arrow keys wrap", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const restingAtComposer = topRowInView();
|
||||
|
||||
const toFarEnd = recordPaintedFrames(topRowInView);
|
||||
await changeAndPaint(<Screen visible options={options} selectedFromBest={99} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
toFarEnd.stop();
|
||||
expect([...new Set(toFarEnd.frames)]).toEqual(["/skill-100"]);
|
||||
expect(isRowInView("/skill-100")).toBe(true);
|
||||
|
||||
const toComposer = recordPaintedFrames(topRowInView);
|
||||
await changeAndPaint(<Screen visible options={options} selectedFromBest={0} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
toComposer.stop();
|
||||
expect([...new Set(toComposer.frames)]).toEqual([restingAtComposer]);
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
it("scrolls towards rows further from the composer when the wheel turns up", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const row = document.querySelector('[aria-label^="/skill-1,"]');
|
||||
row?.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// The best match moved down, out of the popover, by the distance the wheel turned.
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER - 120);
|
||||
expect(isRowInView("/skill-7")).toBe(true);
|
||||
expect(popover.getBoundingClientRect().bottom).toBe(rectOf("composer-input")!.top - 12);
|
||||
});
|
||||
+44
-58
@@ -1,15 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native";
|
||||
StyleSheet,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
|
||||
import { useAnimatedReaction, useAnimatedRef } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
@@ -21,18 +19,10 @@ import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { KeyboardTranslateView, useKeyboardAnchor, useKeyboardShift } from "@/keyboard/shift";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
const OFFSET_FROM_ANCHOR = SPACING[3];
|
||||
|
||||
interface RelativeAnchorRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
hostHeight: number;
|
||||
}
|
||||
import { PopoverFrame } from "./internal/frame";
|
||||
import type { AnchorAtRest } from "./internal/placement";
|
||||
|
||||
interface AutocompletePopoverProps {
|
||||
visible: boolean;
|
||||
@@ -55,8 +45,16 @@ export function AutocompletePopover({
|
||||
}: AutocompletePopoverProps): ReactElement | null {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
|
||||
const anchorRef = useAnimatedRef<View>();
|
||||
// The animated ref returns its native handle, which React 19 would call as a ref cleanup.
|
||||
// The anchor stays a plain View: its style is the caller's Unistyles style.
|
||||
const setAnchorElement = useCallback(
|
||||
(element: View | null) => {
|
||||
anchorRef(element);
|
||||
},
|
||||
[anchorRef],
|
||||
);
|
||||
const [anchorAtRest, setAnchorAtRest] = useState<AnchorAtRest | null>(null);
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
@@ -64,7 +62,9 @@ export function AutocompletePopover({
|
||||
const anchor = useKeyboardAnchor();
|
||||
const { measureAtRest } = anchor;
|
||||
const measurementGeneration = useRef(0);
|
||||
const canMeasure = visible && (options.length === 0 || selectedIndex >= 0);
|
||||
// A retained panel stays mounted while hidden; its anchor cannot be measured until it shows.
|
||||
const isPanelActive = useRetainedPanelActive();
|
||||
const canMeasure = isPanelActive && visible && (options.length === 0 || selectedIndex >= 0);
|
||||
|
||||
const remeasure = useCallback(() => {
|
||||
if (!canMeasure) return;
|
||||
@@ -78,10 +78,11 @@ export function AutocompletePopover({
|
||||
if (generation !== measurementGeneration.current || !anchorRect || !hostRect) {
|
||||
return undefined;
|
||||
}
|
||||
setRelativeAnchorRect({
|
||||
setAnchorAtRest({
|
||||
x: anchorRect.x - hostRect.x,
|
||||
y: anchorRect.y - hostRect.y,
|
||||
width: anchorRect.width,
|
||||
height: anchorRect.height,
|
||||
hostHeight: hostRect.height,
|
||||
});
|
||||
return undefined;
|
||||
@@ -91,7 +92,7 @@ export function AutocompletePopover({
|
||||
useEffect(() => {
|
||||
measurementGeneration.current += 1;
|
||||
if (!canMeasure) {
|
||||
setRelativeAnchorRect(null);
|
||||
setAnchorAtRest(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,47 +115,32 @@ export function AutocompletePopover({
|
||||
[isMoving, remeasure],
|
||||
);
|
||||
|
||||
// Lay out at the anchor's resting position; the KeyboardTranslateView below lifts
|
||||
// it with the anchor. Height changes only at keyboard boundaries.
|
||||
const baseStyle = useMemo(() => {
|
||||
if (!relativeAnchorRect) return null;
|
||||
return inlineUnistylesStyle({
|
||||
position: "absolute" as const,
|
||||
left: relativeAnchorRect.x,
|
||||
width: relativeAnchorRect.width,
|
||||
bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR,
|
||||
});
|
||||
}, [relativeAnchorRect]);
|
||||
|
||||
const anchorY = relativeAnchorRect?.y ?? 0;
|
||||
const reservedLift = anchor.reservedLift;
|
||||
const heightStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
maxHeight: Math.max(
|
||||
0,
|
||||
anchorY - reservedLift.value - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2,
|
||||
),
|
||||
}),
|
||||
[anchorY, reservedLift, safeAreaInsets.top],
|
||||
);
|
||||
|
||||
const showPopover = canMeasure && relativeAnchorRect && baseStyle;
|
||||
|
||||
// The translate view is the popover's own frame, so it lifts only the popover.
|
||||
// The layer covers the host and rides the anchor's keyboard translation; the frame inside it
|
||||
// places the popover. A layer that spans the host keeps the popover inside its parent's bounds,
|
||||
// which Android hit-testing requires.
|
||||
return (
|
||||
<View ref={anchorRef} onLayout={remeasure} style={anchorStyle} collapsable={false}>
|
||||
<View ref={setAnchorElement} onLayout={remeasure} style={anchorStyle} collapsable={false}>
|
||||
{children}
|
||||
{showPopover ? (
|
||||
{canMeasure && anchorAtRest ? (
|
||||
<Portal hostName={portalHostName}>
|
||||
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
|
||||
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
|
||||
<KeyboardTranslateView
|
||||
{...anchor.translation}
|
||||
style={StyleSheet.absoluteFill}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<PopoverFrame
|
||||
anchorRef={anchorRef}
|
||||
anchorAtRest={anchorAtRest}
|
||||
reservedLift={anchor.reservedLift}
|
||||
topInset={safeAreaInsets.top}
|
||||
>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onSelect}
|
||||
status={status}
|
||||
/>
|
||||
</Animated.View>
|
||||
</PopoverFrame>
|
||||
</KeyboardTranslateView>
|
||||
</Portal>
|
||||
) : null}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, type ReactElement } from "react";
|
||||
import Animated, {
|
||||
measure,
|
||||
useAnimatedStyle,
|
||||
useFrameCallback,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { placeAboveAnchor, type PopoverFrameProps } from "./placement";
|
||||
|
||||
/**
|
||||
* The composer grows upward from a fixed bottom edge as its text wraps. Its height is read on the
|
||||
* UI thread every frame, so the popover moves in the frame the composer's new layout mounts; an
|
||||
* asynchronous measurement through JS painted the composer over or away from the popover first.
|
||||
*
|
||||
* The frame mounts only with a measured anchor. Reanimated computes the first style when the hook
|
||||
* first runs and sends later values after the mounting commit, which native paints: a placement
|
||||
* computed before the measurement painted the popover over the composer for a frame on every open.
|
||||
*/
|
||||
export function PopoverFrame({
|
||||
anchorRef,
|
||||
anchorAtRest,
|
||||
reservedLift,
|
||||
topInset,
|
||||
children,
|
||||
}: PopoverFrameProps): ReactElement {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
const anchorHeight = useSharedValue(anchorAtRest.height);
|
||||
const anchorLost = useSharedValue(false);
|
||||
const readAnchorHeight = useCallback(() => {
|
||||
"worklet";
|
||||
if (anchorLost.value) return;
|
||||
const measured = measure(anchorRef);
|
||||
if (!measured) {
|
||||
// The anchor left the shadow tree, usually as the composer unmounts; the portal removes the
|
||||
// frame a commit later. Each failed measure() logs a warning, and a busy JS thread holds
|
||||
// that commit for many frames, so stop measuring and follow the rest measurement.
|
||||
anchorLost.value = true;
|
||||
return;
|
||||
}
|
||||
if (measured.height !== anchorHeight.value) {
|
||||
anchorHeight.value = measured.height;
|
||||
}
|
||||
}, [anchorHeight, anchorLost, anchorRef]);
|
||||
useFrameCallback(readAnchorHeight);
|
||||
|
||||
const style = useAnimatedStyle(() => {
|
||||
const anchorBottom = anchorAtRest.y + anchorAtRest.height;
|
||||
const liveHeight = anchorLost.value ? anchorAtRest.height : anchorHeight.value;
|
||||
return {
|
||||
position: "absolute",
|
||||
left: anchorAtRest.x,
|
||||
width: anchorAtRest.width,
|
||||
...placeAboveAnchor({
|
||||
anchorTop: anchorBottom - liveHeight,
|
||||
hostHeight: anchorAtRest.hostHeight,
|
||||
reservedLift: reservedLift.value,
|
||||
topInset,
|
||||
}),
|
||||
};
|
||||
}, [anchorAtRest, anchorHeight, anchorLost, reservedLift, topInset]);
|
||||
|
||||
return (
|
||||
<Animated.View testID="composer-autocomplete-popover" style={style}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactElement } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { View } from "react-native";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { placeAboveAnchor, type AnchorAtRest, type PopoverFrameProps } from "./placement";
|
||||
|
||||
type AnchorRect = Omit<AnchorAtRest, "height">;
|
||||
|
||||
function rectWithin(anchor: HTMLElement, host: HTMLElement): AnchorRect {
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
return {
|
||||
x: anchorRect.x - hostRect.x,
|
||||
y: anchorRect.y - hostRect.y,
|
||||
width: anchorRect.width,
|
||||
hostHeight: hostRect.height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer resizes as its text wraps and as the window resizes. ResizeObserver callbacks run
|
||||
* after layout and before paint, so committing the new placement synchronously there paints the
|
||||
* popover against the composer's new rect in the same frame.
|
||||
*/
|
||||
export function PopoverFrame({
|
||||
anchorRef,
|
||||
anchorAtRest,
|
||||
reservedLift,
|
||||
topInset,
|
||||
children,
|
||||
}: PopoverFrameProps): ReactElement {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
const frameRef = useRef<View>(null);
|
||||
const [anchor, setAnchor] = useState<AnchorRect>(anchorAtRest);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchorElement = anchorRef.current as unknown as HTMLElement | null;
|
||||
// The frame's parent is the portal layer, which covers the host.
|
||||
const host = (frameRef.current as unknown as HTMLElement | null)?.parentElement;
|
||||
if (!anchorElement || !host) return undefined;
|
||||
setAnchor(rectWithin(anchorElement, host));
|
||||
const observer = new ResizeObserver(() => {
|
||||
flushSync(() => setAnchor(rectWithin(anchorElement, host)));
|
||||
});
|
||||
observer.observe(anchorElement);
|
||||
observer.observe(host);
|
||||
return () => observer.disconnect();
|
||||
}, [anchorAtRest, anchorRef]);
|
||||
|
||||
const style = inlineUnistylesStyle({
|
||||
position: "absolute" as const,
|
||||
left: anchor.x,
|
||||
width: anchor.width,
|
||||
...placeAboveAnchor({
|
||||
anchorTop: anchor.y,
|
||||
hostHeight: anchor.hostHeight,
|
||||
reservedLift: reservedLift.value,
|
||||
topInset,
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<View ref={frameRef} testID="composer-autocomplete-popover" style={style}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { View } from "react-native";
|
||||
import type { AnimatedRef, DerivedValue } from "react-native-reanimated";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
const OFFSET_FROM_ANCHOR = SPACING[3];
|
||||
|
||||
/** The anchor's resting rect, relative to the portal host the popover renders in. */
|
||||
export interface AnchorAtRest {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
hostHeight: number;
|
||||
}
|
||||
|
||||
export interface PopoverFrameProps {
|
||||
anchorRef: AnimatedRef<View>;
|
||||
/** Taken asynchronously; the frame follows the anchor's live size between measurements. */
|
||||
anchorAtRest: AnchorAtRest;
|
||||
reservedLift: DerivedValue<number>;
|
||||
topInset: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Places the popover above an anchor whose top edge is `anchorTop` within the host. */
|
||||
export function placeAboveAnchor(input: {
|
||||
anchorTop: number;
|
||||
hostHeight: number;
|
||||
reservedLift: number;
|
||||
topInset: number;
|
||||
}): { bottom: number; maxHeight: number } {
|
||||
"worklet";
|
||||
return {
|
||||
bottom: input.hostHeight - input.anchorTop + OFFSET_FROM_ANCHOR,
|
||||
maxHeight: Math.max(
|
||||
0,
|
||||
input.anchorTop - input.reservedLift - input.topInset - OFFSET_FROM_ANCHOR * 2,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
FlatList,
|
||||
Text,
|
||||
View,
|
||||
Pressable,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type ListRenderItemInfo,
|
||||
type PressableStateCallbackType,
|
||||
type ViewToken,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -29,7 +28,6 @@ import { Button } from "./button";
|
||||
import { HighlightedText } from "./highlighted-text";
|
||||
import { mutedIconColorMapping } from "./icon-color";
|
||||
import { LoadingSpinner } from "./loading-spinner";
|
||||
import { getScrollOffsetToRevealItem } from "./scroll-reveal";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
|
||||
export type AutocompleteOptionIcon =
|
||||
@@ -80,6 +78,8 @@ const ErrorIcon = withUnistyles(CircleAlert, mutedIconColorMapping);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, mutedIconColorMapping);
|
||||
|
||||
const ROW_ICON_SIZE = 14;
|
||||
const ROW_MIN_HEIGHT = 36;
|
||||
const DEFAULT_MAX_HEIGHT = 220;
|
||||
|
||||
const BOLT_GLYPH_PATTERN = /⚡|️/gu;
|
||||
|
||||
@@ -92,21 +92,17 @@ function removeBoltGlyphs(value?: string): string | undefined {
|
||||
}
|
||||
|
||||
interface AutocompleteRowProps {
|
||||
index: number;
|
||||
option: AutocompleteOption;
|
||||
isSelected: boolean;
|
||||
showArgumentHint: boolean;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
onRowLayout: (index: number, event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
function AutocompleteRow({
|
||||
index,
|
||||
const AutocompleteRow = memo(function AutocompleteRow({
|
||||
option,
|
||||
isSelected,
|
||||
showArgumentHint,
|
||||
onSelect,
|
||||
onRowLayout,
|
||||
}: AutocompleteRowProps) {
|
||||
const label = removeBoltGlyphs(option.label) ?? option.label;
|
||||
const description = removeBoltGlyphs(option.description);
|
||||
@@ -114,10 +110,6 @@ function AutocompleteRow({
|
||||
const Icon = ROW_ICONS[option.icon];
|
||||
const labelMatches = label === option.label ? option.labelMatches : undefined;
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => onRowLayout(index, event),
|
||||
[index, onRowLayout],
|
||||
);
|
||||
const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -129,7 +121,6 @@ function AutocompleteRow({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onLayout={handleLayout}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
// Name and description only; the hint and tag repeat what the name implies.
|
||||
@@ -155,7 +146,7 @@ function AutocompleteRow({
|
||||
{option.tag ? <StatusBadge label={option.tag} size="xs" /> : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -202,94 +193,212 @@ function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
|
||||
);
|
||||
}
|
||||
|
||||
type ListEnd = "composer" | "far";
|
||||
|
||||
/**
|
||||
* The scroller a list is shown in: one for each result set, resting at one end of it. A scroller
|
||||
* starts at its resting end with only the rows nearest it, so the list opens against the composer
|
||||
* and reaches the far end when the selection wraps there without mounting the rows in between.
|
||||
* A scroller that outlived its results would keep its scroll position and its mounted rows: on
|
||||
* native a scroll command reaches the screen after the rows it was meant for.
|
||||
*/
|
||||
function useScroller(
|
||||
options: readonly AutocompleteOption[],
|
||||
selectedIndex: number,
|
||||
): { key: string; restsAt: ListEnd } {
|
||||
const [scroller, setScroller] = useState<{
|
||||
options: readonly AutocompleteOption[];
|
||||
resultSet: number;
|
||||
restsAt: ListEnd;
|
||||
}>({ options, resultSet: 0, restsAt: "composer" });
|
||||
let next = scroller;
|
||||
if (next.options !== options) {
|
||||
next = isSameResultSet(next.options, options)
|
||||
? { ...next, options }
|
||||
: { options, resultSet: next.resultSet + 1, restsAt: "composer" };
|
||||
}
|
||||
const restsAt = endSelected(options, selectedIndex) ?? next.restsAt;
|
||||
if (restsAt !== next.restsAt) {
|
||||
next = { ...next, restsAt };
|
||||
}
|
||||
if (next !== scroller) {
|
||||
setScroller(next);
|
||||
}
|
||||
return { key: `${next.resultSet}:${next.restsAt}`, restsAt: next.restsAt };
|
||||
}
|
||||
|
||||
/** The composer builds a new list on every keystroke and refetch, often of the results it had. */
|
||||
function isSameResultSet(
|
||||
shown: readonly AutocompleteOption[],
|
||||
arrived: readonly AutocompleteOption[],
|
||||
): boolean {
|
||||
return (
|
||||
shown.length === arrived.length && shown.every((option, row) => option.id === arrived[row].id)
|
||||
);
|
||||
}
|
||||
|
||||
/** The end of the list the selection sits at, if it sits at one. The best match is last. */
|
||||
function endSelected(
|
||||
options: readonly AutocompleteOption[],
|
||||
selectedIndex: number,
|
||||
): ListEnd | null {
|
||||
if (selectedIndex === options.length - 1) return "composer";
|
||||
if (selectedIndex === 0) return "far";
|
||||
return null;
|
||||
}
|
||||
|
||||
const FULLY_VISIBLE = { itemVisiblePercentThreshold: 100 };
|
||||
|
||||
function ListPadding() {
|
||||
return <View style={styles.listPadding} />;
|
||||
}
|
||||
|
||||
function keyOfOption(option: AutocompleteOption): string {
|
||||
return option.id;
|
||||
}
|
||||
|
||||
interface AutocompleteRowsProps {
|
||||
/** Nearest the resting end first. */
|
||||
rows: readonly AutocompleteOption[];
|
||||
selectedRow: number;
|
||||
restsAt: ListEnd;
|
||||
maxHeight: number;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows, read from the end the list rests at. Against the composer the list is inverted. Either
|
||||
* way its resting scroll position is that end and the rows nearest it mount first, so a list of any
|
||||
* length opens where it rests.
|
||||
*/
|
||||
function AutocompleteRows({
|
||||
rows,
|
||||
selectedRow,
|
||||
restsAt,
|
||||
maxHeight,
|
||||
onSelect,
|
||||
}: AutocompleteRowsProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const listRef = useRef<FlatList<AutocompleteOption>>(null);
|
||||
const fullyVisibleRows = useRef<{ first: number; last: number } | null>(null);
|
||||
|
||||
// The row the keyboard selected, until it has been brought into the viewport. The row at the
|
||||
// other end is never revealed here: selecting it gives the list a scroller that rests there.
|
||||
const pendingReveal = useRef<number | null>(null);
|
||||
const isPendingRowMeasured = useRef(true);
|
||||
const revealPendingRow = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
const row = pendingReveal.current;
|
||||
const visible = fullyVisibleRows.current;
|
||||
if (!list || row === null || !visible) return;
|
||||
const span = visible.last - visible.first;
|
||||
if (row >= visible.first && row <= visible.last) {
|
||||
pendingReveal.current = null;
|
||||
} else if (row === 0) {
|
||||
list.scrollToOffset({ offset: 0, animated: false });
|
||||
pendingReveal.current = null;
|
||||
fullyVisibleRows.current = { first: 0, last: span };
|
||||
} else {
|
||||
// The row comes in at the edge it was beyond.
|
||||
const isNearer = row < visible.first;
|
||||
isPendingRowMeasured.current = true;
|
||||
list.scrollToIndex({ index: row, viewPosition: isNearer ? 0 : 1, animated: false });
|
||||
// A row that has not mounted has no position yet: the list was sent towards it instead, and
|
||||
// is asked again as rows mount.
|
||||
if (!isPendingRowMeasured.current) return;
|
||||
pendingReveal.current = null;
|
||||
// The list reports what it shows after it has scrolled; the next key press may come first.
|
||||
fullyVisibleRows.current = isNearer
|
||||
? { first: row, last: row + span }
|
||||
: { first: row - span, last: row };
|
||||
}
|
||||
}, []);
|
||||
const handleScrollToIndexFailed = useCallback(
|
||||
(info: { index: number; averageItemLength: number }) => {
|
||||
isPendingRowMeasured.current = false;
|
||||
listRef.current?.scrollToOffset({
|
||||
offset: info.index * info.averageItemLength,
|
||||
animated: false,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
pendingReveal.current = selectedRow < 0 ? null : selectedRow;
|
||||
revealPendingRow();
|
||||
}, [revealPendingRow, selectedRow]);
|
||||
|
||||
const handleViewableItemsChanged = useCallback(
|
||||
({ viewableItems }: { viewableItems: ViewToken<AutocompleteOption>[] }) => {
|
||||
const indexes = viewableItems.flatMap((token) => (token.index === null ? [] : [token.index]));
|
||||
if (indexes.length === 0) return;
|
||||
fullyVisibleRows.current = { first: Math.min(...indexes), last: Math.max(...indexes) };
|
||||
revealPendingRow();
|
||||
},
|
||||
[revealPendingRow],
|
||||
);
|
||||
|
||||
// A viewport that got shorter can leave the selected row outside it.
|
||||
const handleLayout = useCallback(() => {
|
||||
// The list reports the rows its new height shows before the row is checked against them.
|
||||
listRef.current?.recordInteraction();
|
||||
pendingReveal.current = selectedRow < 0 ? null : selectedRow;
|
||||
revealPendingRow();
|
||||
}, [revealPendingRow, selectedRow]);
|
||||
|
||||
const renderRow = useCallback(
|
||||
({ item, index }: ListRenderItemInfo<AutocompleteOption>) => (
|
||||
<AutocompleteRow
|
||||
option={item}
|
||||
isSelected={index === selectedRow}
|
||||
showArgumentHint={!isCompact}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
),
|
||||
[isCompact, onSelect, selectedRow],
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
inverted={restsAt === "composer"}
|
||||
data={rows}
|
||||
renderItem={renderRow}
|
||||
keyExtractor={keyOfOption}
|
||||
// The first commit fills the tallest popover, so the popover never opens short and grows.
|
||||
initialNumToRender={Math.ceil(maxHeight / ROW_MIN_HEIGHT) + 1}
|
||||
removeClippedSubviews={false}
|
||||
viewabilityConfig={FULLY_VISIBLE}
|
||||
onViewableItemsChanged={handleViewableItemsChanged}
|
||||
onLayout={handleLayout}
|
||||
onContentSizeChange={revealPendingRow}
|
||||
onScrollToIndexFailed={handleScrollToIndexFailed}
|
||||
// The list's padding. As a header and footer it counts in the list's own scroll positions.
|
||||
ListHeaderComponent={ListPadding}
|
||||
ListFooterComponent={ListPadding}
|
||||
style={styles.scrollView}
|
||||
keyboardShouldPersistTaps="always"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Autocomplete({
|
||||
options,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
status,
|
||||
maxHeight = 220,
|
||||
maxHeight = DEFAULT_MAX_HEIGHT,
|
||||
}: AutocompleteProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
|
||||
const viewportHeightRef = useRef(0);
|
||||
const scrollOffsetRef = useRef(0);
|
||||
|
||||
const ensureActiveItemVisible = useCallback(() => {
|
||||
if (selectedIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = rowLayoutsRef.current.get(selectedIndex);
|
||||
if (!layout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOffset = getScrollOffsetToRevealItem({
|
||||
currentOffset: scrollOffsetRef.current,
|
||||
viewportHeight: viewportHeightRef.current,
|
||||
itemTop: layout.top,
|
||||
itemHeight: layout.height,
|
||||
});
|
||||
|
||||
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollOffsetRef.current = nextOffset;
|
||||
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}, [selectedIndex]);
|
||||
|
||||
const pinToBottom = useCallback(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
rowLayoutsRef.current.clear();
|
||||
scrollOffsetRef.current = 0;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.length === 0) {
|
||||
return;
|
||||
}
|
||||
pinToBottom();
|
||||
}, [options, pinToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(ensureActiveItemVisible);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [ensureActiveItemVisible, options.length]);
|
||||
|
||||
const handleScrollViewLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
viewportHeightRef.current = event.nativeEvent.layout.height;
|
||||
ensureActiveItemVisible();
|
||||
},
|
||||
[ensureActiveItemVisible],
|
||||
const scroller = useScroller(options, selectedIndex);
|
||||
const rows = useMemo(
|
||||
() => (scroller.restsAt === "composer" ? options.toReversed() : options),
|
||||
[options, scroller.restsAt],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
|
||||
const handleRowLayout = useCallback(
|
||||
(index: number, event: LayoutChangeEvent) => {
|
||||
rowLayoutsRef.current.set(index, {
|
||||
top: event.nativeEvent.layout.y,
|
||||
height: event.nativeEvent.layout.height,
|
||||
});
|
||||
ensureActiveItemVisible();
|
||||
},
|
||||
[ensureActiveItemVisible],
|
||||
);
|
||||
|
||||
const selectedRow =
|
||||
scroller.restsAt === "composer" && selectedIndex >= 0
|
||||
? options.length - 1 - selectedIndex
|
||||
: selectedIndex;
|
||||
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
|
||||
|
||||
const noResultsMessage = t("common.empty.noResults");
|
||||
@@ -308,28 +417,14 @@ export function Autocomplete({
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<AutocompleteRow
|
||||
key={option.id}
|
||||
index={index}
|
||||
option={option}
|
||||
isSelected={index === selectedIndex}
|
||||
showArgumentHint={!isCompact}
|
||||
onSelect={onSelect}
|
||||
onRowLayout={handleRowLayout}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
<AutocompleteRows
|
||||
key={scroller.key}
|
||||
rows={rows}
|
||||
selectedRow={selectedRow}
|
||||
restsAt={scroller.restsAt}
|
||||
maxHeight={maxHeight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -350,14 +445,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
listPadding: {
|
||||
height: theme.spacing[1],
|
||||
},
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
minHeight: ROW_MIN_HEIGHT,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import {
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableProps,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewProps,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
type MenuTriggerState,
|
||||
} from "@/components/ui/menu";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import type { PressHighlightProps } from "@/components/ui/press-highlight.types";
|
||||
|
||||
/**
|
||||
* A menu opened by a long press or a right click, anchored to the point of the gesture rather
|
||||
@@ -109,8 +109,7 @@ export function ContextMenuTrigger({
|
||||
triggerRef,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<PressableProps, "style"> & {
|
||||
highlightStyle?: StyleProp<ViewStyle>;
|
||||
Omit<PressHighlightProps, "style"> & {
|
||||
style?: TriggerStyleProp;
|
||||
enabled?: boolean;
|
||||
enabledOnMobile?: boolean;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user